Initial Merge from Develop

This commit is contained in:
Chloe Herd 2023-04-19 14:16:15 -04:00
commit edb1604c55
125 changed files with 7765 additions and 1478 deletions

View file

@ -15,9 +15,10 @@ module.exports = {
"!src/layouts/vehicle-damage/windshield-options/windshield-options.vue",
"!src/layouts/reveal/**/*.vue",
"!src/ux-components/text-link/**/*.vue",
"!src/common-components/date-picker/**/*.vue",
"!src/common-components/date-picker/**/*.vue", // Temp until unit tests completed
"!src/layouts/vin-lookup/**/*.vue", //Temporary for Quote page testing
"!src/common-components/funnel-header/menu-modal/**/*.vue",
"!src/layouts/schedule/*.vue", // Temp test exclusion while in development
// END
], // ! means exclude from coverage.
testMatch: ["**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)"],

581
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,439 +0,0 @@
<template>
<div id="app">
<fieldset>
<legend class="sr-only">Date Picker</legend>
<div class="calendar-grid-container">
<div class="month-year body-small d-flex align-items-center ps-3 small">
{{ currentMonth }} {{ currentYear }}
</div>
<div class="legend caption d-flex align-items-center justify-content-end">
<span class="legend-circle me-1"></span> &equals; Available
</div>
<div class="nav-back ps-3"><button></button></div>
<div class="nav-forward pe-3"><button></button></div>
<!-- Do the days of the weeek need to be read? -->
<div class="grid-item caption"><span class="sr-only">Sunday</span>S</div>
<div class="grid-item caption"><span class="sr-only">Monday</span>M</div>
<div class="grid-item caption"><span class="sr-only">Tuesday</span>T</div>
<div class="grid-item caption"><span class="sr-only">Wednesday</span>W</div>
<div class="grid-item caption"><span class="sr-only">Thursday</span>T</div>
<div class="grid-item caption"><span class="sr-only">Friday</span>F</div>
<div class="grid-item caption"><span class="sr-only">Saturday</span>S</div>
<div class="grid-item radio-wrapper">
<input disabled type="radio" name="day-of-month" id="sep-25" />
<label class="past-day" for="sep-25"><span>25</span></label>
</div>
<div class="grid-item radio-wrapper">
<input disabled type="radio" name="day-of-month" id="sep-26" />
<label class="past-day" for="sep-26"><span>26</span></label>
</div>
<div class="grid-item radio-wrapper">
<input disabled type="radio" name="day-of-month" id="sep-27" />
<label class="past-day" for="sep-27"><span>27</span></label>
</div>
<div class="grid-item radio-wrapper">
<input disabled type="radio" name="day-of-month" id="sep-28" />
<label class="past-day" for="sep-28"><span>28</span></label>
</div>
<div class="grid-item radio-wrapper">
<input disabled type="radio" name="day-of-month" id="sep-29" />
<label class="past-day" for="sep-29"><span>29</span></label>
</div>
<div class="grid-item radio-wrapper">
<input disabled type="radio" name="day-of-month" id="sep-30" />
<label class="past-day" for="sep-30"><span>30</span></label>
</div>
<!-- Use this as the base calendar item -->
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-1" />
<label
class="highlighted-day selectable-day"
:class="[isCurrentDay ? 'current-day' : '']"
for="oct-1"
><span>1</span>
<span class="sr-only">October 1st</span>
<div v-if="isFirstDayOfMonth" class="current-month first-day">OCT</div>
</label>
</div>
<!-- END -->
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-2" />
<label class="selectable-day" for="oct-2"><span>2</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-3" />
<label class="selectable-day" for="oct-3"><span>3</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-4" />
<label class="selectable-day" for="oct-4"><span>4</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-5" />
<label class="selectable-day" for="oct-5"><span>5</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-6" />
<label class="selectable-day" for="oct-6"><span>6</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-7" />
<label class="selectable-day" for="oct-7"><span>7</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-8" />
<label class="selectable-day" for="oct-8"><span>8</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-9" />
<label class="selectable-day" for="oct-9"><span>9</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-10" />
<label class="selectable-day" for="oct-10"><span>10</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-11" />
<label class="selectable-day" for="oct-11"><span>11</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-12" />
<label class="selectable-day" for="oct-12"><span>12</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-13" />
<label class="selectable-day" for="oct-13"><span>13</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-14" />
<label class="selectable-day" for="oct-14"><span>14</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-15" />
<label class="selectable-day" for="oct-15"><span>15</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-16" />
<label class="selectable-day" for="oct-16"><span>16</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-17" />
<label class="selectable-day" for="oct-17"><span>17</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-18" />
<label class="selectable-day" for="oct-18"><span>18</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-19" />
<label class="selectable-day" for="oct-19"><span>19</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-20" />
<label class="selectable-day" for="oct-20"><span>20</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-21" />
<label class="selectable-day" for="oct-21"><span>21</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-22" />
<label class="selectable-day" for="oct-22"><span>22</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-23" />
<label class="selectable-day" for="oct-23"><span>23</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-24" />
<label class="selectable-day" for="oct-24"><span>24</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-25" />
<label class="selectable-day" for="oct-25"><span>25</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-26" />
<label class="selectable-day" for="oct-26"><span>26</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-27" />
<label class="selectable-day" for="oct-27"><span>27</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-28" />
<label class="selectable-day" for="oct-28"><span>28</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-29" />
<label class="selectable-day" for="oct-29"><span>29</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="nov-30" />
<label class="selectable-day" for="nov-30"><span>30</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="oct-31" />
<label class="selectable-day" for="oct-31"><span>31</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="nov-1" />
<label class="future-day selectable-day" for="nov-1"
><span>1</span>
<div v-if="isFirstDayOfMonth" class="current-month first-day">OCT</div>
</label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="nov-2" />
<label class="future-day selectable-day" for="nov-2"><span>2</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="nov-3" />
<label class="future-day selectable-day" for="nov-3"><span>3</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="nov-4" />
<label class="future-day selectable-day" for="nov-4"><span>4</span></label>
</div>
<div class="grid-item radio-wrapper">
<input type="radio" name="day-of-month" id="nov-5" />
<label class="future-day selectable-day" for="nov-5"><span>5</span></label>
</div>
</div>
</fieldset>
</div>
</template>
<script>
export default {
name: "datePicker",
data() {
return {
currentMonth: "October",
currentYear: "2022",
isFirstDayOfMonth: true,
isCurrentDay: true,
};
},
};
</script>
<style lang="scss">
.calendar-grid-container {
margin: 0 auto;
max-width: 414px;
display: grid;
grid-template-columns: repeat(7, 1fr);
justify-content: center;
align-items: center;
padding: 4px;
.grid-item {
text-align: center;
margin: 10%;
}
.month-year {
grid-area: 1 / 1 / 2 / 4;
text-transform: uppercase;
font-weight: 300;
letter-spacing: 0.75px;
}
.legend {
grid-area: 1 / 4 / 2 / 6;
.legend-circle {
border-radius: 50%;
width: 16px;
height: 16px;
background-color: $blue-100;
border: 1px solid $blue;
}
}
.nav-forward {
grid-area: 1 / 7 / 2 / 8;
display: flex;
justify-content: flex-end;
button {
&:after {
content: "";
position: absolute;
width: 7px;
height: 12px;
background-image: url("data:image/svg+xml,%3Csvg width='7' height='12' viewBox='0 0 7 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.66831 5.99879C6.66955 6.17554 6.60074 6.34558 6.47695 6.47168L1.15501 11.8017C1.02812 11.9287 0.85603 12 0.676587 12C0.497145 12 0.325053 11.9287 0.198168 11.8017C0.0712831 11.6748 7.268e-09 11.5026 8.07183e-09 11.3231C8.87567e-09 11.1436 0.0712831 10.9714 0.198168 10.8445L5.05126 5.99879L0.198168 1.14736C0.0725521 1.02042 0.00248585 0.848755 0.00338306 0.670131C0.00428028 0.491506 0.0760674 0.320554 0.202952 0.194881C0.329837 0.0692091 0.501426 -0.000888818 0.679971 9.54485e-06C0.858515 0.000906955 1.02939 0.0727263 1.15501 0.199668L6.47312 5.52399C6.59843 5.65017 6.66862 5.82092 6.66831 5.99879Z' fill='%231574A1'/%3E%3C/svg%3E%0A");
}
}
}
.nav-back {
grid-area: 1 / 6 / 2 / 7;
display: flex;
justify-content: flex-end;
button {
&:after {
content: "";
position: absolute;
width: 7px;
height: 12px;
background-image: url("data:image/svg+xml,%3Csvg width='7' height='12' viewBox='0 0 7 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.331685 6.00121C0.330445 5.82446 0.399256 5.65442 0.523053 5.52832L5.84499 0.198256C5.97188 0.0713149 6.14397 -8.63821e-08 6.32341 -6.72174e-08C6.50285 -4.80527e-08 6.67495 0.071315 6.80183 0.198256C6.92872 0.325198 7 0.497368 7 0.67689C7 0.856412 6.92872 1.02858 6.80183 1.15552L1.94874 6.00121L6.80183 10.8526C6.92745 10.9796 6.99751 11.1512 6.99662 11.3299C6.99572 11.5085 6.92393 11.6794 6.79705 11.8051C6.67016 11.9308 6.49857 12.0009 6.32003 12C6.14148 11.9991 5.97061 11.9273 5.84499 11.8003L0.526881 6.47601C0.401568 6.34983 0.331375 6.17908 0.331685 6.00121Z' fill='%231574A1'/%3E%3C/svg%3E%0A");
}
}
}
.nav-back,
.nav-forward {
button {
position: relative;
border-radius: 50%;
width: 28px;
height: 28px;
background-color: $blue-100;
border: 1px solid $blue;
display: flex;
justify-content: center;
align-items: center;
&:focus,
&:focus-visible {
border: 2.5px solid $blue;
box-shadow: none;
background-color: $blue-100;
color: $white;
}
}
}
.radio-wrapper {
position: relative;
display: flex;
justify-content: center;
align-items: center;
outline: none;
input[type="radio"] {
position: absolute; //override bootstrap
height: 0;
opacity: 0;
&:focus-visible + label {
box-shadow: 0 0 0 2.5px $blue;
}
&:focus + label,
&:checked:focus + label {
box-shadow: 0 0 0 3px #fff, 0 0 0 5.5px #1574a1;
background-color: $blue;
color: $white;
&.past-day {
box-shadow: none;
background-color: transparent;
color: $gray-500;
font-weight: normal;
}
&.current-day {
&:after {
background-color: $white;
}
.first-day {
color: $white;
}
}
}
&:checked + label {
color: $white;
background: $blue;
box-shadow: 0 0 0 3px $white, 0 0 0 5.5px $blue;
&:after {
background-color: $white;
}
.first-day {
color: $white;
}
}
}
.first-day {
position: absolute;
font-size: 9px;
font-weight: 500;
color: $gray-600;
top: 0;
z-index: 1;
}
label {
position: relative;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
width: 40px;
height: 40px;
min-width: 40px;
border-radius: 50%;
&.past-day {
color: $gray-500;
background-color: transparent;
border: 1px solid transparent;
pointer-events: none;
}
&.selectable-day {
color: $blue;
background-color: $blue-100;
border: 1px solid $blue;
}
&.current-day {
&:after {
content: "";
width: 0.25rem;
height: 0.25rem;
border-radius: 50%;
background-color: $blue;
position: absolute;
top: 28px;
}
.first-day {
color: $blue;
}
}
&.future-day {
color: $gray-600;
background-color: transparent;
border: 1px solid transparent;
}
span {
&.small {
font-size: 0.75rem;
color: $gray-550;
}
}
&:hover,
&:checked {
@include media-breakpoint-up(sm) {
box-shadow: 0 0 0 4px transparent;
background-color: $blue;
color: $white;
&:after {
background-color: $white;
}
}
cursor: pointer;
&.current-day {
+ .first-day {
color: $white;
}
}
.first-day {
color: $white;
}
}
+ p {
display: none;
}
}
}
}
</style>

View file

@ -7,8 +7,9 @@ const applicationConfig = {
CURRENT_ENVIRONMENT: process.env.VUE_APP_CURRENT_ENVIRONMENT, // "Localhost", "Dev", "QA", and "Prod"
APPLICATION_NAME: "FixMyGlass",
APPLICATION_ABBREVIATION: "fmg",
PAGE_QUERYSTRING: "fmgPage",
SITE_ENTRY_TRIGGER_VALUE: "FixMyGlass",
CASH_ACCOUNT_NUMBER: 167132,
CASH_PARENT_ACCOUNT_NUMBER: 167132,
};
export { applicationConfig };

View file

@ -78,6 +78,14 @@ const endpoints = {
url: "/parts/api/v1/parts/rain-defense",
method: "GET",
},
GetMobileFeePart: {
url: "/parts/api/v1/parts/mobile-fee",
method: "GET",
},
GetServiceabilityDetails: {
url: "/location/api/v1/location/serviceability-details",
method: "GET",
},
GetSupportingItems: {
url: "/parts/api/v1/parts/supporting-items",
method: "POST",

View file

@ -25,6 +25,8 @@ const errorMessages = {
"Invalid VIN. Please make sure that you entered the correct 17-digit, alpha-numeric number. VINs do not contain the letters I, O, or Q",
OPTION_REQUIRED: "Please select an option",
VEHICLE_REQUIRED: "Please select a vehicle",
MOBILE_LOCATION_REQUIRED: "Please enter your service address",
DATE_REQUIRED: "Please select a date",
};
export { errorMessages };

View file

@ -0,0 +1,16 @@
const monthsOfYear = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
export { monthsOfYear };

View file

@ -29,11 +29,13 @@ const storeActions = {
GET_CAPABILITY_QUESTIONS: "getCapabilityQuestions",
GET_PART_FROM_CAPABILITY_QUESTION_ANSWER: "getPartFromCapabilityQuestionAnswer",
GET_MOLDING_QUESTIONS: "getMoldingQuestions",
GET_MOBILE_FEE_PART: "getMobileFeePart",
GET_SERVICEABILITY_DETAILS: "getServiceabilityDetails",
SAVE_SESSION: "saveSession",
LOAD_SESSION: "loadSession",
UPDATE_STORE_WITH_SAVE_SESSION_RESPONSE: "updateStoreWithSaveSessionResponse",
VALIDATE_ZIP: "validateZip",
PRICE_ORDER_ITEMS: "priceOrderItems",
PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA: "priceOrderItemsAndSaveServerData",
LOG_EXPERIMENT_EXPOSURE: "logExperimentExposure",
LOG_PAGE_VIEW: "logPageView",
LOG_CUSTOM_EVENT: "logCustomEvent",
@ -68,7 +70,7 @@ const storeActions = {
SAVE_MOLDING_QUESTION_ANSWERS: "saveMoldingQuestionAnswers",
SAVE_CAPABILITY_QUESTION_ANSWERS: "saveCapabilityQuestionAnswers",
SAVE_PAYMENT_TYPE: "savePaymentType",
SAVE_ACCOUNT_NUMBER: "saveAccountNumber",
SAVE_PARENT_ACCOUNT_NUMBER: "saveParentAccountNumber",
SAVE_SUPPORTING_ITEMS: "saveSupportingItems",
SAVE_VAPS: "saveVaps",
};

View file

@ -33,7 +33,7 @@ import {
handleButtonComponentFocus,
handleInputComponentBlur,
} from "@/helpers/button-question-focus-helper";
import { inputButtonProps } from "@/common-components/base-input-button/button-functionality-props";
import { inputButtonProps } from "@/digital-components/base-input-button/button-functionality-props";
export default {
name: "base-input-button",
@ -207,5 +207,6 @@ input {
opacity: 0;
height: 0.1px; // NOTE: cannot be zero or Safari can't put focus on it
width: 0;
position: absolute;
}
</style>

View file

@ -4,8 +4,15 @@ export const inputButtonProps = {
required: true,
},
modelValue: {
type: [Array, String, Number],
required: true,
validator: function (prop) {
return (
prop === null ||
Array.isArray(prop) ||
typeof prop === "string" ||
(typeof prop === "number" && !Number.isNaN(prop))
);
},
},
isMultiSelect: Boolean,
groupName: {

View file

@ -1,6 +1,9 @@
import { shallowMount, mount } from "@vue/test-utils";
import buttonQuestion from "@/common-components/button-question/button-question";
import buttonQuestion from "./button-question";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import crypto from "crypto";
global.crypto = crypto;
describe("buttonQuestion.vue", () => {
it("Should show overflow classes on fieldset if isOverflowScrollable is true", () => {

View file

@ -1,10 +1,14 @@
<!-- Documented in confluence https://safelite.atlassian.net/wiki/spaces/DC/pages/76644418/Button+Question+Component -->
<!-- Documented in confluence
https://safelite.atlassian.net/wiki/spaces/DC/pages/76644418/Button+Question+Component -->
<template>
<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"
:class="{ 'small-question-text': isSmallQuestionText }">
<span class="fw-bold w-100">{{ questionText }}</span>
</div>
@ -52,6 +56,7 @@
:lastValuePushedToGa="lastValuePushedToGa"
:setLastValuePushedToGa="setLastValuePushedToGa"
:suppressError="suppressError"
@buttonEvent="handleButtonEvent"
v-model="selectedValues" />
<!-- For nested questions -->
<transition name="fade" mode="out-in">
@ -68,7 +73,10 @@
</fieldset>
</div>
<div class="row form-test-error mt-1">
<error-message :name="formatString(groupName)" v-if="!suppressError"></error-message>
<error-message
class="small"
:name="formatString(groupName)"
v-if="!suppressError"></error-message>
</div>
</div>
</template>
@ -78,7 +86,7 @@ import listButton from "@/ux-components/list-button/list-button";
import listButtonHorizontal from "@/ux-components/list-button-horizontal/list-button-horizontal";
import listCard from "@/ux-components/list-card/list-card";
import radio from "@/ux-components/radio/radio";
import { ErrorMessage } from "vee-validate";
import { useField, ErrorMessage } from "vee-validate";
export default {
name: "buttonQuestion",
@ -119,6 +127,30 @@ export default {
useTextForValue: Boolean,
valueToLogType: String,
additionalButtonStyling: String,
isSmallQuestionText: Boolean,
customButtonQuestionId: String,
},
setup(props) {
const propsClone = Object.assign({}, props);
const modelValue = propsClone.modelValue;
const fieldOptions = {
value: modelValue,
initialValue: modelValue,
};
const { errorMessage, handleBlur, handleChange, meta, validate, errors, resetField } =
useField(props.groupName, props.validationRules, fieldOptions);
return {
errorMessage,
handleBlur,
handleChange,
validate,
meta,
errors,
resetField,
};
},
beforeMount() {
if (this.buttonTypeObject) {
@ -132,13 +164,15 @@ export default {
},
computed: {
getFieldSetClasses() {
if (this.isOverflowScrollable) {
return "container-fluid overflow-scroll position-absolute px-5 pt-1 py-0";
} else if (this.buttonTypeString == "listCard") {
return "w-100";
} else {
return "";
}
const baseClasses = this.isOverflowScrollable
? "container-fluid overflow-scroll position-absolute px-5 pt-1 py-0"
: this.buttonTypeString == "listCard"
? "w-100"
: "";
const withSmallQuestionClass = this.isSmallQuestionText
? baseClasses + " small-question-text"
: baseClasses;
return withSmallQuestionClass;
},
getComponentLoopWrapperClasses() {
let classes;
@ -205,11 +239,20 @@ export default {
},
methods: {
formatString(str) {
return str?.replaceAll(" ", "-");
return String(str).replaceAll(" ", "-");
},
setLastValuePushedToGa(lastValuePushedToGa) {
this.lastValuePushedToGa = lastValuePushedToGa;
},
handleButtonEvent(event) {
const eventName = event.eventName;
this.$emit(`buttonEvent.${eventName}`, event.args);
},
},
watch: {
modelValue() {
this.resetField();
},
},
components: {
listButton,
@ -251,19 +294,18 @@ export default {
text-align: center;
}
}
.vehicle-parts {
.question-text {
.small-question-text {
&.question-text {
span {
font-size: 0.875rem;
text-align: left;
margin: 0 0 0.5rem 0;
margin: 1rem 0 0.5rem 0;
}
}
.question-text {
&.question-text {
margin: 0;
}
fieldset {
& fieldset {
.ui-radio {
margin: 0;
}

View file

@ -0,0 +1,437 @@
import datePicker from "./date-picker";
// Supporting Files
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
describe("date-picker.vue", () => {
describe("initial setup", () => {
test("Creates a date object from today", () => {
// Arrange
const { wrapper } = setupMocks({});
// Act
const testResult = wrapper.vm.todayDate instanceof Date;
// Assert
expect(testResult).toEqual(true);
wrapper.unmount();
});
});
// describe("formatNumberToSetDigits", () => {
// const testScenarios = [
// [0, 3, "000"],
// [8, 2, "08"],
// [345, 2, "345"],
// [4567, 0, "4567"],
// ];
// test.each(testScenarios)(
// "when num is %s and digits is %s, formatNumberToSetDigits should return %s",
// async (num, digits, expectedReturn) => {
// // Arrange
// const { wrapper } = setupMocks({});
// // Act
// const fnReturn = wrapper.vm.formatNumberToSetDigits(num, digits);
// // Assert
// expect(fnReturn).toEqual(expectedReturn);
// }
// );
// });
// describe("formatDate", () => {
// test("Can format a date as MM/dd/yyyy", () => {
// // Arrange
// const { wrapper } = setupMocks({});
// const testDate = new Date("01-26-2023");
// // Act
// const testResult = wrapper.vm.formatDate(testDate);
// // Assert
// expect(testResult).toEqual("01/26/2023");
// wrapper.unmount();
// });
// // test("Can get correct initialFirstDate from today", () => {
// // // Arrange
// // const { wrapper } = setupMocks({
// // propsData: {
// // todayDate: "Thu Jan 26 2023",
// // },
// // });
// // // Act
// // const testResult = wrapper.vm.initialFirstDate;
// // const testResultAsDateString = testResult.toDateString();
// // // Assert
// // expect(testResultAsDateString).toEqual("Sun Jan 22 2023");
// // wrapper.unmount();
// // });
// });
// describe("calendarData", () => {
// test("should return an array", async () => {
// // Arrange
// const { wrapper } = setupMocks({});
// // Act
// const testResult = await wrapper.vm.calendarData;
// // Assert
// expect(Array.isArray(testResult)).toBe(true);
// wrapper.unmount();
// });
// describe("each month should have keys monthLabel, yearNum, dates, firstDateDayIndex", () => {
// const testScenarios = [
// ["monthLabel", true, "string"],
// ["yearNum", true, "number"],
// ["dates", true, "object"],
// ["firstDateDayIndex", true, "number"],
// ["monthClass", true, "string"],
// ];
// test.each(testScenarios)(
// "test for %s key should return %s and be type %s",
// async (label, test, type) => {
// // Arrange
// const { wrapper } = setupMocks({});
// const containsMonthLabel = (obj) => Object.hasOwn(obj, label);
// const isCorrectType = (obj) => typeof obj[label] === type;
// const isTruthyOrZero = (obj) => (obj[label] || obj[label] === 0 ? true : false);
// // Act
// const calendarData = await wrapper.vm.calendarData;
// // console.log("calendarData: ", calendarData);
// // Assert
// expect(calendarData.every(containsMonthLabel)).toBe(true);
// expect(calendarData.every(isCorrectType)).toBe(true);
// expect(calendarData.every(isTruthyOrZero)).toBe(true);
// wrapper.unmount();
// }
// );
// });
// describe("the current month's week which includes today", () => {
// const testScenarios = [
// ["2022-02-05T03:00:00", 1, 2, true],
// ["2024-02-05T03:00:00", 4, 0, true],
// ["2020-01-15T03:00:00", 12, 0, true],
// ];
// test.each(testScenarios)(
// "the first date of the week including the date %s should be the date number %s and be day index %s",
// async (todayDateString, startDate, dayOfWeekIndex) => {
// // Arrange
// const { wrapper } = setupMocks({
// propsData: {
// todayOverrideDateString: todayDateString,
// },
// });
// // Act
// const calendarData = await wrapper.vm.calendarData;
// const currentMonthIndex = calendarData.findIndex((month) => {
// console.log("month: ", month);
// return month.monthClass === "currentMonth";
// });
// console.log("calendarData ", calendarData);
// console.log("currentMonthIndex ", currentMonthIndex);
// // Assert
// expect(calendarData[currentMonthIndex].dates[0].dateNum === startDate).toBe(
// true
// );
// expect(
// calendarData[currentMonthIndex].dates[0].firstDateDayIndex ===
// dayOfWeekIndex
// ).toBe(true);
// wrapper.unmount();
// }
// );
// });
// test("the current month's dates array should start with the first day of the week which includes today", async () => {
// // Arrange
// const { wrapper } = setupMocks({});
// const hasAFirstInDates = (obj) => obj?.dates[0].dateNum === 1;
// // Act
// const calendarData = await wrapper.vm.calendarData;
// const currentMonthIndex = calendarData.findIndex((month) => {
// return month.monthClass === "currentMonth";
// });
// // Assert
// expect(calendarData[currentMonthIndex].dates[0].dateNum === 1).toBe(true);
// wrapper.unmount();
// });
// test("for each month (other than current month), the dates array should have the 1st of the month", async () => {
// // Arrange
// const { wrapper } = setupMocks({});
// const hasAFirstInDates = (obj) => obj?.dates[0].dateNum === 1;
// // Act
// const calendarData = await wrapper.vm.calendarData;
// // Assert
// expect(calendarData.every(hasAFirstInDates)).toBe(true);
// wrapper.unmount();
// });
// describe("for the current month in calendarData, the dates array should have correct days of month", () => {
// const testScenarios = [
// ["2022-02-05T03:00:00", 28, true],
// ["2024-02-05T03:00:00", 29, true],
// ["2020-01-15T03:00:00", 31, true],
// ];
// test.each(testScenarios)(
// "the current month including the date %s should have %s days in its dates array",
// async (dateString, noOfDays) => {
// // Arrange
// const { wrapper } = setupMocks({
// propsData: {
// todayOverrideDateString: dateString,
// },
// });
// // Act
// const calendarData = await wrapper.vm.calendarData;
// const currentMonthIndex = calendarData.findIndex((month) => {
// return month.monthClass === "currentMonth";
// });
// // Assert
// expect(calendarData[currentMonthIndex].dates.length === noOfDays).toBe(true);
// wrapper.unmount();
// }
// );
// // test.each(testScenarios)(
// // "the date %s should have %s days in dates array",
// // async (dateString, noOfDays) => {
// // // Arrange
// // const { wrapper } = setupMocks({
// // propsData: {
// // todayOverrideDateString: dateString,
// // },
// // });
// // const hasCorrectNumberOfDates = (obj) => obj?.dates.length === noOfDays;
// // // Act
// // const calendarData = await wrapper.vm.calendarData;
// // // Assert
// // expect(calendarData.every(hasCorrectNumberOfDates)).toBe(true);
// // wrapper.unmount();
// // }
// // );
// });
// // describe("for each object in array, the dates array should have correct days of month", () => {
// // const testScenarios = [
// // ["2022-02-05T03:00:00", 28, true],
// // ["2024-02-05T03:00:00", 29, true],
// // ["2020-01-15T03:00:00", 31, true],
// // ];
// // test.each(testScenarios)(
// // "the date %s should have %s days in dates array",
// // async (dateString, noOfDays) => {
// // // Arrange
// // const { wrapper } = setupMocks({
// // propsData: {
// // todayOverrideDateString: dateString,
// // },
// // });
// // const hasCorrectNumberOfDates = (obj) => obj?.dates.length === noOfDays;
// // // Act
// // const calendarData = await wrapper.vm.calendarData;
// // // Assert
// // expect(calendarData.every(hasCorrectNumberOfDates)).toBe(true);
// // wrapper.unmount();
// // }
// // );
// // });
// // test.only("each object in array should have a monthLabel", () => {
// // // Arrange
// // const { wrapper } = setupMocks({});
// // // const testTodayDate = new Date("01-26-2023");
// // // Act
// // const testResult = wrapper.vm.calendarData;
// // const containsMonthLabel = (month) => Object.hasOwn(month, 'monthLabel');
// // // Assert
// // expect(testResult.every(containsMonthLabel)).toBe(true);
// // });
// // test.only("array should have a month object with same month as today", () => {
// // // Arrange
// // const { wrapper } = setupMocks({});
// // // const testTodayDate = new Date("01-26-2023");
// // // Act
// // // const testResult = wrapper.vm.calendarData;
// // console.log("wrapper.vm.calendarData: ", wrapper.vm.calendarData)
// // // Assert
// // // expect(wrapper.vm.calendarData).toEqual("something");
// // // expect(wrapper.vm.calendarDataOld).toEqual("something");
// // expect(wrapper.vm.calendarData[1]).toMatchObject({
// // "dates": [
// // {
// // "dateNum": 1
// // },
// // {
// // "dateNum": 2
// // },
// // {
// // "dateNum": 3
// // },
// // {
// // "dateNum": 4
// // },
// // {
// // "dateNum": 5
// // },
// // {
// // "dateNum": 6
// // },
// // {
// // "dateNum": 7
// // },
// // {
// // "dateNum": 8
// // },
// // {
// // "dateNum": 9
// // },
// // {
// // "dateNum": 10
// // },
// // {
// // "dateNum": 11
// // },
// // {
// // "dateNum": 12
// // },
// // {
// // "dateNum": 13
// // },
// // {
// // "dateNum": 14
// // },
// // {
// // "dateNum": 15
// // },
// // {
// // "dateNum": 16
// // },
// // {
// // "dateNum": 17
// // },
// // {
// // "dateNum": 18
// // },
// // {
// // "dateNum": 19
// // },
// // {
// // "dateNum": 20
// // },
// // {
// // "dateNum": 21
// // },
// // {
// // "dateNum": 22
// // },
// // {
// // "dateNum": 23
// // },
// // {
// // "dateNum": 24
// // },
// // {
// // "dateNum": 25
// // },
// // {
// // "dateNum": 26
// // },
// // {
// // "dateNum": 27
// // },
// // {
// // "dateNum": 28
// // },
// // {
// // "dateNum": 29
// // },
// // {
// // "dateNum": 30
// // },
// // {
// // "dateNum": 31
// // }
// // ],
// // "monthLabel": "January",
// // "yearNum": 2023
// // });
// // wrapper.unmount();
// // });
// });
});
function setupMocks(mountOptionsMockData = {}) {
const initialMountOptionsMockData = {
// router: {
// navigate: jest.fn(),
// navigateWithSaving: jest.fn(),
// navigateWithoutSaving: jest.fn(),
// },
// actionList: [
// // {
// // actionName: storeActions.SAVE_PART_QUESTION_ANSWERS,
// // data: {},
// // },
// // {
// // actionName: storeActions.GET_PARTS,
// // data: {},
// // },
// ],
// store: {
// // getters: store.getters,
// // commit: store.commit,
// },
};
const mountOptions = getMountOptions(
Object.assign(initialMountOptionsMockData, mountOptionsMockData)
);
// console.log("mountOptions: ", mountOptions)
const wrapper = shallowMount(datePicker, mountOptions);
return { wrapper };
}

View file

@ -0,0 +1,640 @@
<template>
<div class="date-picker text-center" :class="calendarViewDirection">
<fieldset v-if="months">
<legend class="sr-only">Date Picker</legend>
<div
v-for="month in months"
:key="`${month.monthLabel}-${month.yearNum.toString()}-${months.length}`">
<div
class="calendar-grid-container"
:class="[isInitialView === true ? 'initial-view' : '', month.monthClass]">
<div class="month-year body-small d-flex align-items-center ps-3 small">
{{ month.monthLabel }} {{ month.yearNum.toString() }}
</div>
<div
v-if="calendarViewDirection === 'future'"
class="legend caption d-flex align-items-center justify-content-end">
<span class="legend-circle me-1"></span> &equals; Available
</div>
<div class="nav-back ps-3"><button></button></div>
<div class="nav-forward pe-3"><button></button></div>
<!-- Do the days of the weeek need to be read? -->
<div class="grid-item caption"><span class="sr-only">Sunday</span>S</div>
<div class="grid-item caption"><span class="sr-only">Monday</span>M</div>
<div class="grid-item caption"><span class="sr-only">Tuesday</span>T</div>
<div class="grid-item caption"><span class="sr-only">Wednesday</span>W</div>
<div class="grid-item caption"><span class="sr-only">Thursday</span>T</div>
<div class="grid-item caption"><span class="sr-only">Friday</span>F</div>
<div class="grid-item caption"><span class="sr-only">Saturday</span>S</div>
<div
v-for="date in month.dates"
:key="`${month.monthLabel}-${date.dateNum.toString()}-${months.length}`"
class="grid-item radio-wrapper"
:class="[
date.dateNum === 1 ? 'first-day-' + month.startDateDayIndex : '',
date.dayClasses,
]">
<input
:disabled="!isSelectableDate(date.inputValue)"
type="radio"
name="day-of-month"
v-model="selectedDate"
:value="date.inputValue"
:id="`${month.monthLabel}-${date.dateNum.toString()}`" />
<label :for="`${month.monthLabel}-${date.dateNum.toString()}`">
<span>{{ date.dateNum.toString() }}</span>
</label>
</div>
</div>
</div>
</fieldset>
<button
v-if="calendarViewDirection === 'future'"
type="button"
class="btn btn-link"
@click="goForward">
View more dates
</button>
</div>
</template>
<script>
// Supporting files
import { monthsOfYear } from "@/constants/scheduling.js";
const SelectableDaysOptions = Object.freeze({
CUSTOM: "custom",
PAST: "past",
});
export default {
name: "datePicker",
data() {
return {
calendarData: [],
monthsBeforeToLoadOffset: 0,
monthsAfterToLoadOffset: 1,
selectableDatesData: [], // NOTE: uses monthNum (1-based), NOT monthIndex (0-based)
isInitialView: true,
initialViewRowsToShow: 5,
initialViewRowsTally: 0,
};
},
props: {
selectableDates: {
type: String,
validator(value) {
return Object.values(SelectableDaysOptions).includes(value);
},
default: SelectableDaysOptions.PAST,
},
modelValue: {
type: Object,
},
todayOverrideDateString: {
// only used for unit tests to override today's date
type: String,
default: null,
},
customSelectableDatesCallback: {
type: Function,
default() {
return [];
},
},
},
computed: {
todayDate() {
return this.todayOverrideDateString
? new Date(this.todayOverrideDateString)
: new Date();
},
months() {
if (this.calendarData?.length < 1) {
this.setCalendarData();
}
return this.calendarData;
},
calendarViewDirection() {
if (this.selectableDates === "past") return "past";
if (this.selectableDates === "custom") return "future";
return "none";
},
selectedDate: {
get() {
return this.modelValue;
},
set(newSelectedDate) {
this.$emit("update:modelValue", newSelectedDate);
},
},
},
methods: {
goForward() {
if (this.isInitialView) {
this.isInitialView = false; // 1st click removes hidden styling
} else {
this.addMonthData(); // 2nd click adds 1 month data
}
},
setCalendarData() {
// GENERATE MONTHS AND PUSH THEM INTO ARRAY
const months = [];
if (this.calendarViewDirection === "future") {
// first 0, then 1
for (let i = 0; i <= this.monthsAfterToLoadOffset; i++) {
months.push(this.getMonthData(i));
}
} else if (this.calendarViewDirection === "past") {
// first 0, then -1
for (let i = 0; i >= 0 - this.monthsAfterToLoadOffset; i--) {
months.unshift(this.getMonthData(i));
}
} else {
// TK - IF A CALENDAR WITH BOTH PAST AND FUTURE WAS EVER NEEDED
// for (let i = this.monthsAfterToLoadOffset; i >= this.monthsBeforeToLoadOffset; i--) {
// months.push(this.getMonthDataPAST(i));
// }
}
this.calendarData = months;
},
getMonthData(offset) {
const direction = this.calendarViewDirection; // "past" or "future"
let yearNum;
let monthIndex;
let monthClass = "";
let initialViewEndDate; // only used for setCalendarData FUTURE
let initialViewStartDate; // only used for setCalendarData PAST
let currentWeekEndDateNum; // only used for setCalendarData PAST
let currentWeekStartDateNum; // only used for setCalendarData FUTURE
let firstMonthDayTally; // only used for setCalendarData on 1st month generated
const datesArray = [];
const todayDateNum = this.todayDate.getDate();
if (typeof offset !== "undefined") {
// offset only passed during initial setup with setCalendarData
yearNum = this.todayDate.getFullYear();
monthIndex = this.todayDate.getMonth() + offset;
if (direction === "future" && offset > 0) {
while (monthIndex > 11) {
monthIndex = monthIndex - 12;
yearNum++;
}
} else if (direction === "past" && offset < 0) {
while (monthIndex < 0) {
monthIndex = 12 + monthIndex;
yearNum--;
}
}
} else {
// used only when adding an additional month
if (direction === "future") {
// get last day of current calendar data
const lastMonthInCalendarData = this.calendarData[this.calendarData.length - 1];
if (lastMonthInCalendarData.monthIndex === 11) {
monthIndex = 0;
yearNum = lastMonthInCalendarData.yearNum + 1;
} else {
monthIndex = lastMonthInCalendarData.monthIndex + 1;
yearNum = lastMonthInCalendarData.yearNum;
}
}
if (direction === "past") {
// get last day of current calendar data
const firstMonthInCalendarData = this.calendarData[0];
if (firstMonthInCalendarData.monthIndex === 0) {
monthIndex = 11;
yearNum = firstMonthInCalendarData.yearNum - 1;
} else {
monthIndex = firstMonthInCalendarData.monthIndex - 1;
yearNum = firstMonthInCalendarData.yearNum;
}
}
}
const todayDayIndex = this.todayDate.getDay(); // get day of week index of today (0-6)
currentWeekStartDateNum =
todayDayIndex > todayDateNum ? 1 : todayDateNum - todayDayIndex; // FUTURE
const monthEndDate = new Date(yearNum, monthIndex + 1, 0); // BOTH
let monthEndDateNum = monthEndDate.getDate(); // BOTH
currentWeekEndDateNum = todayDateNum + 6 - todayDayIndex; // PAST, aka Sat. (ok if it's larger than the month end?)
if (offset === 0 && direction === "past" && monthEndDateNum > currentWeekEndDateNum)
monthEndDateNum = currentWeekEndDateNum; // PAST
const monthStartDateNum =
offset === 0 && direction === "future" ? currentWeekStartDateNum : 1; // FUTURE
const monthStartDate = new Date(yearNum, monthIndex, monthStartDateNum); // BOTH
const startDateDayIndex = monthStartDate.getDay(); // FUTURE
const endDateDayIndex = monthEndDate.getDay(); // PAST
if (typeof offset !== "undefined") {
if (offset === 0 && direction === "future")
firstMonthDayTally = monthStartDateNum - startDateDayIndex; // FUTURE (starts low, counts up)
if (offset === 0 && direction === "past")
firstMonthDayTally = currentWeekEndDateNum; // PAST (starts high, counts down)
while (
direction === "future" &&
offset === 0 &&
firstMonthDayTally < monthEndDateNum
) {
// FUTURE
firstMonthDayTally = firstMonthDayTally + 7;
this.initialViewRowsTally++;
}
while (
direction === "past" &&
offset === 0 &&
firstMonthDayTally > monthStartDateNum
) {
// PAST
firstMonthDayTally = firstMonthDayTally - 7;
this.initialViewRowsTally++;
}
if (Math.abs(offset) === 1) {
if (this.initialViewRowsTally < this.initialViewRowsToShow) {
initialViewEndDate = 6 - startDateDayIndex + monthStartDateNum; // FUTURE
initialViewStartDate = monthEndDateNum - endDateDayIndex; // PAST
this.initialViewRowsTally++;
} else {
monthClass = monthClass + " month-hidden";
}
while (this.initialViewRowsTally < this.initialViewRowsToShow) {
initialViewEndDate = initialViewEndDate + 7;
initialViewStartDate = initialViewStartDate - 7;
this.initialViewRowsTally++;
}
}
}
if (this.selectableDates === "custom") {
const monthStart = {
year: yearNum,
month: monthIndex + 1,
date: offset === 0 ? todayDateNum : monthStartDateNum,
};
const monthEnd = {
year: monthEndDate.getFullYear(),
month: monthEndDate.getMonth() + 1,
date: monthEndDateNum,
};
// get available dates
this.updateSelectableDates(monthStart, monthEnd);
}
// populate datesArray
for (let i = monthStartDateNum; i <= monthEndDateNum; i++) {
let dayClasses = "";
const thisDate = {
// NOTE: uses monthNum (1-based), NOT monthIndex (0-based)
year: yearNum,
month: monthIndex + 1,
date: i,
};
if (offset === 0 && i === todayDateNum) {
dayClasses += "current-day";
}
if (offset === 0 && i < todayDateNum && direction === "future") {
dayClasses += "unavailable-day";
}
if (offset === 0 && i > todayDateNum && direction === "past") {
dayClasses += "unavailable-day";
}
if (Math.abs(offset) === 1 && direction === "future" && i > initialViewEndDate) {
dayClasses += "day-hidden";
}
if (Math.abs(offset) === 1 && direction === "past" && i < initialViewStartDate) {
dayClasses += "day-hidden";
}
if (this.selectableDates === "custom" && this.isSelectableDate(thisDate)) {
dayClasses += " selectable-day";
}
const dateObject = {
dateNum: i,
dayClasses: dayClasses,
inputValue: thisDate,
};
datesArray.push(dateObject);
}
const monthToAdd = {
monthLabel: monthsOfYear[monthIndex],
monthIndex: monthIndex,
yearNum: yearNum,
dates: datesArray,
startDateDayIndex: startDateDayIndex,
monthClass: monthClass,
};
return monthToAdd;
},
addMonthData() {
const monthToAdd = this.getMonthData();
if (this.calendarViewDirection === "future") {
this.calendarData.push(monthToAdd);
}
if (this.calendarViewDirection === "past") {
this.calendarData.unshift(monthToAdd);
}
},
isSelectableDate(thisDate) {
const testForDate = (dateInArray) => {
return (
dateInArray.date === thisDate.date &&
dateInArray.month === thisDate.month &&
dateInArray.year === thisDate.year
);
};
const isSelectable =
this.selectableDatesData.findIndex(testForDate) > -1 ? true : false;
return isSelectable;
},
updateSelectableDates(monthStart, monthEnd) {
this.customSelectableDatesCallback(monthStart, monthEnd)?.forEach((newObj) => {
const index = this.selectableDatesData.findIndex(
(obj) =>
obj.year === newObj.year &&
obj.month === newObj.month &&
obj.date === newObj.date
);
if (index === -1) this.selectableDatesData.push(newObj);
});
},
},
};
</script>
<style lang="scss" scoped>
.date-picker {
.calendar-grid-container {
margin: 0 auto;
max-width: 414px;
display: grid;
grid-template-columns: repeat(7, 1fr);
justify-content: center;
align-items: center;
padding: 4px;
.grid-item {
text-align: center;
margin: 10%;
&.first-day-,
&.first-day-0 {
grid-column-start: 1;
}
&.first-day-1 {
grid-column-start: 2;
}
&.first-day-2 {
grid-column-start: 3;
}
&.first-day-3 {
grid-column-start: 4;
}
&.first-day-4 {
grid-column-start: 5;
}
&.first-day-5 {
grid-column-start: 6;
}
&.first-day-6 {
grid-column-start: 7;
}
}
.month-year {
grid-area: 1 / 1 / 2 / 5;
text-transform: uppercase;
font-weight: 300;
letter-spacing: 0.75px;
}
.legend {
grid-area: 1 / 5 / 2 / 8;
.legend-circle {
border-radius: 50%;
width: 16px;
height: 16px;
background-color: $blue-100;
border: 1px solid $blue;
}
}
.nav-back,
.nav-forward {
display: none;
}
.radio-wrapper {
position: relative;
display: flex;
justify-content: center;
align-items: center;
outline: none;
input[type="radio"] {
position: absolute; //override bootstrap
height: 0;
opacity: 0;
&:focus-visible + label {
box-shadow: 0 0 0 2.5px $blue;
}
&:focus + label,
&:checked:focus + label {
box-shadow: 0 0 0 3px #fff, 0 0 0 5.5px #1574a1;
background-color: $blue;
color: $white;
&.past-day {
box-shadow: none;
background-color: transparent;
color: $gray-500;
font-weight: normal;
}
&.current-day {
&:after {
background-color: $white;
}
.first-day {
color: $white;
}
}
}
&:checked + label {
color: $white;
background: $blue;
box-shadow: 0 0 0 3px $white, 0 0 0 5.5px $blue;
&:after {
background-color: $white;
}
.first-day {
color: $white;
}
}
}
.first-day {
position: absolute;
font-size: 9px;
font-weight: 500;
color: $gray-600;
top: 0;
z-index: 1;
}
label {
position: relative;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
width: 40px;
height: 40px;
min-width: 40px;
border-radius: 50%;
span {
&.small {
font-size: 0.75rem;
color: $gray-550;
}
}
&:hover,
&:checked {
@include media-breakpoint-up(sm) {
box-shadow: 0 0 0 4px transparent;
background-color: $blue;
color: $white;
&:after {
background-color: $white;
}
}
cursor: pointer;
&.current-day {
+ .first-day {
color: $white;
}
}
.first-day {
color: $white;
}
}
+ p {
display: none;
}
}
&.selectable-day {
label {
color: $blue;
background-color: $blue-100;
border: 1px solid $blue;
}
}
&.past-day,
&.future-day,
&.unavailable-day {
label {
color: $gray-500;
background-color: transparent;
border: 1px solid transparent;
pointer-events: none;
}
}
&.current-day {
label {
&:after {
content: "";
width: 0.25rem;
height: 0.25rem;
border-radius: 50%;
background-color: $blue;
position: absolute;
top: 28px;
}
.first-day {
color: $blue;
}
}
}
}
&.initial-view {
&.month-hidden {
display: none;
}
.day-hidden {
display: none;
}
}
}
.btn-link {
font-weight: 500;
text-underline-offset: 4px;
}
.past {
.calendar-grid-container {
.month-year {
grid-area: 1 / 1 / 2 / 6;
}
.nav-forward {
grid-area: 1 / 7 / 2 / 8;
display: flex;
justify-content: flex-end;
button {
&:after {
content: "";
position: absolute;
width: 7px;
height: 12px;
background-image: url("data:image/svg+xml,%3Csvg width='7' height='12' viewBox='0 0 7 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.66831 5.99879C6.66955 6.17554 6.60074 6.34558 6.47695 6.47168L1.15501 11.8017C1.02812 11.9287 0.85603 12 0.676587 12C0.497145 12 0.325053 11.9287 0.198168 11.8017C0.0712831 11.6748 7.268e-09 11.5026 8.07183e-09 11.3231C8.87567e-09 11.1436 0.0712831 10.9714 0.198168 10.8445L5.05126 5.99879L0.198168 1.14736C0.0725521 1.02042 0.00248585 0.848755 0.00338306 0.670131C0.00428028 0.491506 0.0760674 0.320554 0.202952 0.194881C0.329837 0.0692091 0.501426 -0.000888818 0.679971 9.54485e-06C0.858515 0.000906955 1.02939 0.0727263 1.15501 0.199668L6.47312 5.52399C6.59843 5.65017 6.66862 5.82092 6.66831 5.99879Z' fill='%231574A1'/%3E%3C/svg%3E%0A");
}
}
}
.nav-back {
grid-area: 1 / 6 / 2 / 7;
display: flex;
justify-content: flex-end;
button {
&:after {
content: "";
position: absolute;
width: 7px;
height: 12px;
background-image: url("data:image/svg+xml,%3Csvg width='7' height='12' viewBox='0 0 7 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.331685 6.00121C0.330445 5.82446 0.399256 5.65442 0.523053 5.52832L5.84499 0.198256C5.97188 0.0713149 6.14397 -8.63821e-08 6.32341 -6.72174e-08C6.50285 -4.80527e-08 6.67495 0.071315 6.80183 0.198256C6.92872 0.325198 7 0.497368 7 0.67689C7 0.856412 6.92872 1.02858 6.80183 1.15552L1.94874 6.00121L6.80183 10.8526C6.92745 10.9796 6.99751 11.1512 6.99662 11.3299C6.99572 11.5085 6.92393 11.6794 6.79705 11.8051C6.67016 11.9308 6.49857 12.0009 6.32003 12C6.14148 11.9991 5.97061 11.9273 5.84499 11.8003L0.526881 6.47601C0.401568 6.34983 0.331375 6.17908 0.331685 6.00121Z' fill='%231574A1'/%3E%3C/svg%3E%0A");
}
}
}
.nav-back,
.nav-forward {
button {
position: relative;
border-radius: 50%;
width: 28px;
height: 28px;
background-color: $blue-100;
border: 1px solid $blue;
display: flex;
justify-content: center;
align-items: center;
&:focus,
&:focus-visible {
border: 2.5px solid $blue;
box-shadow: none;
background-color: $blue-100;
color: $white;
}
}
}
}
}
}
</style>

View file

@ -1,5 +1,6 @@
import { shallowMount } from "@vue/test-utils";
import dropdownQuestion from "./dropdown-question";
import crypto from "crypto";
// Mock CMS content
const questionText = "Question Text";
@ -11,6 +12,8 @@ const mockMixin = {
},
};
global.crypto = crypto;
// TODO: Remove the following from dropdown-question.vue -> :class="(errors && errors.length) || hasError ? 'has-error' : ''"
// It is not being used.
describe("dropdownQuestion.vue", () => {
@ -46,23 +49,6 @@ describe("dropdownQuestion.vue", () => {
expect(label.text()).toContain(questionText);
});
it("Should return input id as the id of the select field", async () => {
// Arrange
const wrapper = shallowMount(dropdownQuestion, {
propsData: {
inputId: "input ID",
options: {},
},
mixins: [mockMixin],
});
// Act
const select = wrapper.find("select");
// Assert
expect(select.attributes().id).toEqual("input ID");
});
it("Should render the 'questionText' data value as the aria-label attribute.", async () => {
// Arrange
const wrapper = shallowMount(dropdownQuestion, {
@ -118,7 +104,7 @@ describe("dropdownQuestion.vue", () => {
const wrapper = shallowMount(dropdownQuestion, {
propsData: {
options: {},
modelValue: 0,
modelValue: "0",
},
mixins: [mockMixin],
});

View file

@ -1,19 +1,23 @@
<template>
<div class="dropdown-question" :class="errorMessage || hasError ? 'has-error' : ''">
<div
class="dropdown-question"
:class="(errors && errors.length) || hasError ? 'has-error' : ''">
<label
:for="inputId"
:for="dropdownId"
:aria-label="questionText"
class="form-label"
v-html="labelText"></label>
v-html="questionText"></label>
<select
v-model="selectedOption"
class="form-select"
:id="inputId"
:name="inputId"
:id="dropdownId"
:name="dropdownId"
:aria-disabled="isDisabled"
:disabled="isDisabled"
:aria-required="isRequired"
:validationRules="validationRules">
:validationRules="validationRules"
:placeHolderText="placeHolderText">
<option v-if="placeHolderText" value="" selected>{{ placeHolderText }}</option>
<option v-for="(value, name, index) in options" :value="name" :key="index">
{{ value }}
</option>
@ -31,7 +35,7 @@ export default {
name: "dropdown-question",
props: {
modelValue: String,
inputId: String,
customInputId: String,
options: {
type: Object,
required: true,
@ -41,8 +45,14 @@ export default {
validationRules: String,
cmsWidgetName: String,
hasError: Boolean,
placeHolderText: String,
customDropdownId: String,
},
setup(props) {
const dropdownId = !props.customDropdownId
? `dropdown-${crypto.randomUUID()}`
: props.customDropdownId;
const propsClone = Object.assign({}, props);
const modelValue = propsClone.modelValue;
let initialValue;
@ -62,55 +72,36 @@ export default {
initialValue: initialValue,
};
const { errorMessage, handleBlur, handleChange, meta } = useField(
props.inputId,
const { errorMessage, handleBlur, handleChange, meta, errors } = useField(
dropdownId,
props.validationRules,
fieldOptions
);
return {
dropdownId,
errorMessage,
handleBlur,
handleChange,
meta,
errors,
};
},
mounted() {
this.$emit("dropdownQuestionEvent.inputIdAssigned", this.inputId);
},
computed: {
questionText() {
return this.getCmsContent(this.cmsWidgetName, "QuestionText");
},
selectedOption: {
get: function () {
return this.modelValue;
return !this.modelValue ? "" : this.modelValue;
},
set: function (newValue) {
this.$emit("update:modelValue", newValue);
},
},
labelText: {
get: function () {
const noBreakChar = "&NoBreak;";
var questionText = "";
if (this.disableAutoFill) {
var words = this.questionText.toString().split(/[ ]+/);
words.forEach(function (word) {
const position = 1;
word = [
word.toString().slice(0, position),
noBreakChar,
word.toString().slice(position),
].join("");
questionText += `${word} `;
});
questionText = questionText.trimEnd();
} else {
questionText = this.questionText.toString();
}
return questionText;
},
},
},
watch: {
selectedOption(newValue) {

View file

@ -0,0 +1,255 @@
jest.mock("vee-validate", () => ({
useForm: jest.fn(),
}));
const mockValidate = (returnValue) => jest.fn(async () => Promise.resolve({ valid: returnValue }));
const mockMeta = (returnValue) => jest.fn(async () => Promise.resolve(returnValue));
import { shallowMount } from "@vue/test-utils";
import modal from "./modal";
import crypto from "crypto";
import { useForm } from "vee-validate";
import { Modal } from "bootstrap";
const footerButtonText = "Sample footer text here.";
const headerText = "Sample header text here.";
global.crypto = crypto;
describe("modal.vue", () => {
it("Should display modal header text when headerText is defined", async () => {
// Arrange / Act
const fakeMeta = {
touched: true,
dirty: true,
valid: true,
validated: true,
};
useForm.mockReturnValue({
meta: mockMeta(fakeMeta),
validate: mockValidate(true),
});
const wrapper = shallowMount(modal, {
props: {
headerText: headerText,
footerButtonText: footerButtonText,
},
attachTo: document.body,
});
// Assert
expect(wrapper.html()).toEqual(expect.stringContaining(headerText));
});
it("Should display footer button text when footerButtonText is defined", async () => {
// Arrange / Act
const fakeMeta = {
touched: true,
dirty: true,
valid: true,
validated: true,
};
useForm.mockReturnValue({
meta: mockMeta(fakeMeta),
validate: mockValidate(true),
});
const wrapper = shallowMount(modal, {
props: {
footerButtonText: footerButtonText,
},
attachTo: document.body,
});
// Assert
expect(wrapper.html()).toEqual(expect.stringContaining(footerButtonText));
});
it("Should emit 'footer-button-event' if the form is valid", async () => {
// Arrange
const fakeMeta = {
touched: true,
dirty: true,
valid: true,
validated: true,
};
useForm.mockReturnValue({
meta: mockMeta(fakeMeta),
validate: mockValidate(true),
});
const resetButtonStyle = jest.fn();
const wrapper = shallowMount(modal, {
props: {
footerButtonText: footerButtonText,
headerText: headerText,
},
attachTo: document.body,
});
wrapper.vm.resetButtonStyle = resetButtonStyle;
// Act
const buttonMain = wrapper.findComponent({ ref: "modalButtonMain" });
await buttonMain.trigger("click-event");
// Assert
expect(wrapper.emitted("footer-button-event")).toBeTruthy();
});
it("Should not emit 'footer-button-event' if the form is invalid", async () => {
// Arrange
const fakeMeta = {
touched: true,
dirty: true,
valid: false,
validated: true,
};
useForm.mockReturnValue({
meta: mockMeta(fakeMeta),
validate: mockValidate(false),
});
const resetButtonStyle = jest.fn();
const wrapper = shallowMount(modal, {
props: {
footerButtonText: footerButtonText,
headerText: headerText,
},
attachTo: document.body,
});
wrapper.vm.resetButtonStyle = resetButtonStyle;
// Act
const buttonMain = wrapper.findComponent({ ref: "modalButtonMain" });
await buttonMain.trigger("click-event");
// Assert
expect(wrapper.emitted("footer-button-event")).toBeFalsy();
});
it("Should have a disabled footer button when the form has not been touched", async () => {
// Arrange / Act
const fakeMeta = {
touched: true,
dirty: true,
valid: true,
validated: true,
};
useForm.mockReturnValue({
meta: mockMeta(fakeMeta),
validate: mockValidate(true),
});
const resetButtonStyle = jest.fn();
const wrapper = shallowMount(modal, {
props: {
footerButtonText: footerButtonText,
headerText: headerText,
},
attachTo: document.body,
});
wrapper.vm.resetButtonStyle = resetButtonStyle;
// Assert
expect(wrapper.vm.isFooterButtonDisabled).toBe(true);
});
it("Should have a disabled footer button when the form is invalid", async () => {
// Arrange / Act
const fakeMeta = {
touched: true,
dirty: true,
valid: true,
validated: true,
};
useForm.mockReturnValue({
meta: mockMeta(fakeMeta),
validate: mockValidate(true),
});
const resetButtonStyle = jest.fn();
const wrapper = shallowMount(modal, {
props: {
footerButtonText: footerButtonText,
headerText: headerText,
},
attachTo: document.body,
});
wrapper.vm.resetButtonStyle = resetButtonStyle;
// Assert
expect(wrapper.vm.isFooterButtonDisabled).toBe(true);
});
it("Should call bootstrap Modal method 'show' when calling 'openModal'", async () => {
// Arrange
const fakeMeta = {
touched: true,
dirty: true,
valid: true,
validated: true,
};
useForm.mockReturnValue({
meta: mockMeta(fakeMeta),
validate: mockValidate(true),
});
const showMock = jest.spyOn(Modal.prototype, "show");
const resetButtonStyle = jest.fn();
const wrapper = shallowMount(modal, {
props: {
footerButtonText: footerButtonText,
headerText: headerText,
},
attachTo: document.body,
});
wrapper.vm.resetButtonStyle = resetButtonStyle;
// Act
wrapper.vm.openModal();
// Assert
expect(showMock).toHaveBeenCalled();
});
it("Should call bootstrap Modal method 'hide' when calling 'closeModal'", async () => {
// Arrange
const fakeMeta = {
touched: true,
dirty: true,
valid: true,
validated: true,
};
useForm.mockReturnValue({
meta: mockMeta(fakeMeta),
validate: mockValidate(true),
});
const hideMock = jest.spyOn(Modal.prototype, "hide");
const resetButtonStyle = jest.fn();
const wrapper = shallowMount(modal, {
props: {
footerButtonText: footerButtonText,
headerText: headerText,
},
attachTo: document.body,
});
wrapper.vm.resetButtonStyle = resetButtonStyle;
// Act
wrapper.vm.openModal();
wrapper.vm.closeModal();
// Assert
expect(hideMock).toHaveBeenCalled();
});
});

View file

@ -2,39 +2,38 @@
<!-- Modal -->
<div
class="modal fade modal-component"
v-on="{ 'hidden.bs.modal': resetButtonStyle }"
:id="cmsWidgetName"
v-on="{ 'hidden.bs.modal': onModalClosed, 'shown.bs.modal': onModalOpened }"
:id="modalId"
tabindex="-1"
aria-labelledby="ModalComponentLabel"
aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<div class="modal-header mb-2 mt-2">
<label
v-if="headerText"
class="modal-title d-flex justify-content-center pb-0 w-100">
{{ headerText }}
</label>
<button
ref="closeButton"
type="button"
class="btn-close"
data-bs-dismiss="modal"
@mousedown="closeModal"
aria-label="Close"></button>
</div>
<div class="modal-body ps-4 pe-4 pt-5 pb-4">
<img :src="ModalImage" class="mw-100 d-flex mx-auto mb-4" alt="" />
<h5 class="mb-4" v-html="ModalHeadline"></h5>
<p class="fw-bold mb-2 subheader-text" v-html="ModalSubheadertext"></p>
<p class="mb-0" v-html="ModalBodyText"></p>
<p
class="my-4 caption modal-sub-body"
v-if="ModalSubBodyText"
v-html="ModalSubBodyText"></p>
<div class="modal-body ps-5 pe-5 pb-4 pt-0">
<slot></slot>
</div>
<div class="modal-footer px-5 py-4">
<buttonMain
<modalButtonMain
isPrimary
class="w-100"
ref="buttonMain"
suppressLoader
:buttonText="ModalCloseButtonText"
@click-event="buttonClick"
data-bs-dismiss="modal" />
ref="modalButtonMain"
loaderColor="white"
:buttonText="footerButtonText"
@click-event="validateAndEmit"
:class="isFooterButtonDisabled && 'form-test-invalid'" />
</div>
</div>
</div>
@ -42,40 +41,71 @@
</template>
<script>
import buttonMain from "@/ux-components/button-main/button-main";
import modalButtonMain from "@/digital-components/modal/ux-components/modal-button-main/modal-button-main";
import { Modal } from "bootstrap";
import { useForm } from "vee-validate";
export default {
name: "modal",
props: {
cmsWidgetName: String,
headerText: String,
footerButtonText: String,
onModalOpenedCallback: {
type: Function,
},
onModalClosedCallback: {
type: Function,
},
},
computed: {
ModalHeadline() {
return this.getCmsContent(this.cmsWidgetName, "HeaderText");
},
ModalSubheadertext() {
return this.getCmsContent(this.cmsWidgetName, "SubheaderText");
},
ModalBodyText() {
return this.getCmsContent(this.cmsWidgetName, "BodyText");
},
ModalSubBodyText() {
return this.getCmsContent(this.cmsWidgetName, "BodyText2");
},
ModalImage() {
return this.getCmsContent(this.cmsWidgetName, "Image");
},
ModalCloseButtonText() {
return this.getCmsContent(this.cmsWidgetName, "FooterText");
},
setup() {
const modalId = `modal-${crypto.randomUUID()}`;
const { meta, validate, resetForm } = useForm();
return {
modalId,
meta,
validate,
resetForm,
};
},
methods: {
async validateAndEmit() {
const validationResult = await this.validate();
if (validationResult.valid) {
this.$emit("footer-button-event");
} else {
this.resetButtonStyle();
}
},
resetButtonStyle() {
this.$refs.buttonMain.resetButtonStyle();
this.$refs.modalButtonMain.resetButtonStyle();
},
onModalOpened() {
this.onModalOpenedCallback?.();
},
onModalClosed() {
this.resetButtonStyle();
this.onModalClosedCallback?.();
},
openModal() {
const modal = Modal.getOrCreateInstance(document.getElementById(this.modalId));
modal.show();
},
closeModal() {
const modal = Modal.getInstance(document.getElementById(this.modalId));
modal.hide();
},
},
computed: {
isFooterButtonDisabled() {
if (!this.meta.touched) {
return !this.meta.valid;
}
return !this.meta.dirty || !this.meta.valid;
},
},
components: {
buttonMain,
modalButtonMain,
},
};
</script>
@ -92,9 +122,17 @@ export default {
.modal-header {
border-bottom: none;
.btn-close {
position: absolute;
right: 1rem;
top: 1rem;
background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M15.6874 1.82179L9.50889 8L15.6874 14.1782C16.1042 14.595 16.1042 15.2707 15.6874 15.6875C15.4843 15.8907 15.2146 16 14.9328 16C14.6514 16 14.3818 15.8906 14.1787 15.6875L8.00017 9.50934L1.82171 15.6875C1.6182 15.8907 1.34876 16 1.06708 16C0.785733 16 0.516056 15.8906 0.312965 15.6875C-0.10429 15.2706 -0.10429 14.5952 0.312797 14.1784L6.03276 8.45867L6.49146 8L0.312945 1.82177C-0.10429 1.40483 -0.10429 0.729418 0.312797 0.31262C0.728936 -0.104145 1.40489 -0.104051 1.82185 0.31262L7.54152 6.03202L8.00017 6.49065L14.1786 0.312472C14.5955 -0.104107 15.2707 -0.104201 15.6874 0.312452C16.1042 0.729289 16.1042 1.40496 15.6874 1.82179Z' fill='%231574A1'/%3E%3C/svg%3E%0A");
opacity: 1;
}
.modal-title {
font-weight: 500;
color: rgb(0, 0, 0);
}
}
&.modal-component {
.modal-dialog {

View file

@ -0,0 +1,187 @@
import { shallowMount } from "@vue/test-utils";
import modalButtonMain from "./modal-button-main";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import { nextTick } from "vue";
describe("modal-button-main.vue", () => {
it("Should return btn-primary class", () => {
// Arrange/Act
const wrapper = shallowMount(
modalButtonMain,
setupMocks({
propsData: {
isPrimary: true,
},
})
);
const button = wrapper.find("button");
// Assert
expect(button.attributes("class")).toContain("btn-primary");
});
it("Should return aria-disabled state", () => {
// Arrange/Act
const wrapper = shallowMount(
modalButtonMain,
setupMocks({
propsData: {
isDisabled: true,
},
})
);
const button = wrapper.find("button");
// Assert
expect(button.attributes()["aria-disabled"]).toEqual("true");
});
it("Should return loader color", async () => {
// Arrange
const wrapper = shallowMount(
modalButtonMain,
setupMocks({
propsData: {
loaderColor: "blue",
loaderEnabled: true,
},
})
);
// Act
wrapper.vm.clicked();
await nextTick();
// Assert
const loader = wrapper.find("loader-stub");
expect(loader.attributes("class")).toContain("blue");
});
it("Should return loader position", async () => {
// Arrange
const wrapper = shallowMount(
modalButtonMain,
setupMocks({
propsData: {
loaderPosition: "right",
loaderEnabled: true,
},
})
);
// Act
wrapper.vm.clicked();
await nextTick();
// Assert
const loader = wrapper.find("loader-stub");
expect(loader.attributes("class")).toContain("right");
});
it("Should set 'isLoaderDisplayed' to false when calling 'removeLoader'", async () => {
// Arrange
const wrapper = shallowMount(
modalButtonMain,
setupMocks({
propsData: {
loaderPosition: "right",
loaderEnabled: true,
},
})
);
wrapper.setData({
isLoaderDisplayed: true,
});
// Act
wrapper.vm.removeLoader();
await nextTick();
// Assert
const loader = wrapper.find("loader-stub");
expect(wrapper.vm.isLoaderDisplayed).toBe(false);
});
it("Should set 'isLoaderDisplayed' to false when calling 'resetButtonStyle'", async () => {
// Arrange
const wrapper = shallowMount(
modalButtonMain,
setupMocks({
propsData: {
loaderPosition: "right",
loaderEnabled: true,
},
})
);
wrapper.setData({
isLoaderDisplayed: true,
});
// Act
wrapper.vm.resetButtonStyle();
await nextTick();
// Assert
expect(wrapper.vm.isLoaderDisplayed).toBe(false);
});
it("Should emit 'click-event' event when clicking if the button is enabled", async () => {
// Arrange
const wrapper = shallowMount(
modalButtonMain,
setupMocks({
propsData: {
loaderPosition: "right",
loaderEnabled: true,
isDisabled: false,
},
})
);
const buttonElement = wrapper.find("button");
// Act
buttonElement.trigger("click");
await nextTick();
// Assert
expect(wrapper.emitted("click-event")).toBeTruthy();
});
it("Should not emit 'click-event' event when clicking if the button is disabled", async () => {
// Arrange
const wrapper = shallowMount(
modalButtonMain,
setupMocks({
propsData: {
loaderPosition: "right",
loaderEnabled: true,
isDisabled: true,
},
})
);
const buttonElement = wrapper.find("button");
// Act
buttonElement.trigger("click");
await nextTick();
// Assert
expect(wrapper.emitted("click-event")).toBeFalsy();
});
});
function setupMocks(mountOptionsMockData = {}) {
const defaultMountOptions = { route: { query: { fmgPage: "page-name" } } };
const baseMountOptions = getMountOptions(
Object.assign(defaultMountOptions, mountOptionsMockData)
);
const allMountOptions = Object.assign(defaultMountOptions, baseMountOptions);
return allMountOptions;
}

View file

@ -0,0 +1,153 @@
<template>
<button
type="button"
:aria-disabled="isDisabled"
class="btn d-flex align-items-center justify-content-center py-3 px-4 delay"
:class="[
isPrimary ? 'btn-primary' : 'btn-secondary',
isFloat ? 'float-end' : '',
isLoaderDisplayed ? 'has-loader' : '',
]"
@click="clicked">
<span class="m-0">{{ this.buttonText }}</span>
<loader
class="ms-2"
v-if="isLoaderDisplayed && !suppressLoader"
v-bind:class="[this.loaderColor, this.loaderPosition]" />
</button>
</template>
<script>
import loader from "@/ux-components/loader/loader";
export default {
name: "modalButtonMain",
props: {
isPrimary: Boolean,
buttonText: String,
isDisabled: Boolean,
loaderColor: String,
loaderPosition: String,
isFloat: Boolean,
suppressLoader: Boolean,
},
data() {
return {
isLoaderDisplayed: false,
};
},
methods: {
removeLoader() {
this.isLoaderDisplayed = false;
},
clicked() {
this.pushEventToGA(
this.$route.query[this.queryStrings.FMG_PAGE],
this.GaActions.CLICKED,
this.buttonText,
true
);
if (!this.isDisabled) {
this.isLoaderDisplayed = true;
this.$emit("click-event");
}
},
resetButtonStyle() {
this.isLoaderDisplayed = false;
},
},
components: {
loader,
},
};
</script>
<style lang="scss">
.btn {
&.btn-primary {
position: relative;
background: linear-gradient(270deg, $blue 0%, $blue-800 100%);
border: none;
border-radius: $border-radius-lg;
color: $white;
justify-content: center;
font-weight: 500;
@media (hover: hover) {
background: linear-gradient(270deg, $blue 0%, $blue-800 100%);
}
&:focus {
box-shadow: 0 0 0 3px, 0 0 0 5.5px $blue-700;
}
&:focus, // Mouse, touch, stylus focus
&:focus-visible {
// Keyboard focus for accessibility
outline: none;
box-shadow: 0 0 0 3px, 0 0 0 5.5px $blue-700;
color: $white;
background: linear-gradient(270deg, rgba(6, 87, 124, 1) 0%, rgba(6, 87, 124, 1) 100%);
}
&:disabled {
background: $gray-200 !important;
background: linear-gradient(270deg, $gray-200 0%, $gray-200 100%) !important;
color: $gray-600 !important;
font-weight: 400;
height: 48px;
border: none;
border-radius: $border-radius-lg;
cursor: pointer;
pointer-events: all;
}
&.has-loader {
color: $white;
background: $blue-700;
box-shadow: 0 0 0 3px, 0 0 0 5.5px $blue-700;
pointer-events: none;
}
&.delay {
// fixes flicker while transitioning between states
transition: background 0s 0s ease-in-out;
}
}
&.btn-secondary {
position: relative;
background: transparent;
border: 1px solid $blue;
border-radius: $border-radius-lg;
color: $blue;
font-weight: 500;
transition: all 150ms linear;
height: 3rem;
&:hover {
color: $white;
@include blue-gradient;
}
&:focus, // Mouse, touch, stylus focus
&:focus-visible {
// Keyboard focus for accessibility
outline: none;
box-shadow: 0 0 0 3px $white, 0 0 0 5.5px $blue-700;
color: $white;
@include blue-gradient;
}
&:disabled {
background: transparent;
color: $gray-550 !important;
font-weight: 400;
height: 48px;
border: 1px solid $gray-550;
border-radius: $border-radius-lg;
cursor: pointer;
pointer-events: all;
}
&.has-loader {
color: $white;
@include blue-gradient;
pointer-events: none;
}
&.delay {
// fixes flicker while transitioning between states
transition: background 0s 0s ease-in-out;
}
}
}
</style>

View file

@ -1,5 +1,5 @@
import { shallowMount } from "@vue/test-utils";
import questionChain from "@/common-components/question-chain/question-chain.vue";
import questionChain from "./question-chain.vue";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import { nextTick } from "vue";

View file

@ -17,7 +17,7 @@
</template>
<script>
import buttonQuestion from "@/common-components/button-question/button-question";
import buttonQuestion from "@/digital-components/button-question/button-question";
import { useValidateForm } from "vee-validate";
export default {
@ -82,7 +82,7 @@ export default {
},
methods: {
handleAnswer(question, returnedAnswer) {
/*
/*
returnedAnswer example format:
"1|answer|DD11132|Yes"
*/

View file

@ -10,6 +10,18 @@ describe("modal.vue", () => {
expect(wrapper.html()).toEqual(expect.stringContaining(mockCmsContent["Text"]));
});
it("Should display the custom text when it is passed in", async () => {
// Act
const wrapper = shallowMount(TextBlock, {
mixins: [mockMixin],
props: {
customText: "customText",
},
});
expect(wrapper.html()).toEqual(expect.stringContaining("customText"));
});
it("Should contain the typeStyle class as defined by the prop", async () => {
// Act
const wrapper = shallowMount(TextBlock, {
@ -18,6 +30,7 @@ describe("modal.vue", () => {
});
expect(wrapper.html()).toEqual(expect.stringContaining(mockProps["typeStyle"]));
});
it("Should contain the justifyText class as defined by the prop", async () => {
// Act
const wrapper = shallowMount(TextBlock, {
@ -26,6 +39,7 @@ describe("modal.vue", () => {
});
expect(wrapper.html()).toEqual(expect.stringContaining(mockProps["justifyText"]));
});
it("Should contain the fontWeight class as defined by the prop", async () => {
// Act
const wrapper = shallowMount(TextBlock, {

View file

@ -1,6 +1,6 @@
<template>
<div
class="text-block d-flex w-100 mt-2"
class="text-block w-100 mt-2"
:class="[justifyText, typeStyle, fontWeight]"
v-html="this.TextBlockCopy"></div>
</template>
@ -9,6 +9,7 @@
export default {
name: "textBlock",
props: {
customText: String, // used to allow the insert of token values into textblock
justifyText: String, // left, right, center
typeStyle: String, // h1-h6, body, small, label, caption (see Figma or Confluence documentation)
fontWeight: String, // bold=500, default is 400
@ -16,6 +17,9 @@ export default {
},
computed: {
TextBlockCopy() {
if (this.customText) {
return this.customText;
}
return this.getCmsContent(this.cmsWidgetName, "Text");
},
},

View file

@ -1,5 +1,6 @@
import { shallowMount } from "@vue/test-utils";
import textboxQuestion from "./textbox-question";
import crypto from "crypto";
// Mock CMS content
const questionText = "Question Text";
@ -12,6 +13,8 @@ const mockMixin = {
};
const maska = jest.fn();
global.crypto = crypto;
describe("textboxQuestion.vue", () => {
it("Should render a text input", async () => {
// Arrange
@ -73,45 +76,6 @@ describe("textboxQuestion.vue", () => {
expect(paragraph.attributes("class")).toContain("form-control");
});
it("Should render the 'questionText' data value as the label text when disableAutoFill is false.", async () => {
// Arrange
const wrapper = shallowMount(textboxQuestion, {
global: {
directives: {
maska: maska,
},
},
mixins: [mockMixin],
});
// Act
const label = wrapper.find("label");
// Assert
expect(label.text()).toContain(questionText);
});
it("Should return input id as the id of the input field", async () => {
// Arrange
const wrapper = shallowMount(textboxQuestion, {
global: {
directives: {
maska: maska,
},
},
propsData: {
inputId: "input ID",
},
mixins: [mockMixin],
});
// Act
const input = wrapper.find("input");
// Assert
expect(input.attributes().id).toEqual("input ID");
});
it("Should render the 'questionText' data value as the aria-label attribute.", async () => {
// Arrange
const wrapper = shallowMount(textboxQuestion, {

View file

@ -1,11 +1,20 @@
<template>
<div class="textbox-question" :class="(errors && errors.length) || hasError ? 'has-error' : ''">
<div
class="textbox-question"
:class="[
(errors && errors.length) || hasError ? 'has-error' : '',
hideInput ? 'hide-input' : '',
]">
<label
v-if="displayQuestionText"
:for="inputId"
:aria-label="questionText"
class="form-label"
:class="[questionAlignment === 'center' ? 'text-center w-100 mb-5' : '']"
v-html="labelText"></label>
:class="[
questionAlignment === 'center' ? 'text-center w-100 mb-5' : '',
hideInput ? 'hide-input' : '',
]"
v-html="questionText"></label>
<div
class="input-wrapper"
:class="[
@ -29,6 +38,7 @@
hasIcon ? 'has-icon' : '',
iconRight ? 'icon-right' : '',
cornerStyle === 'rounded' ? 'rounded-pill' : '',
hideInput ? 'hide-input' : '',
]"
:validationRules="validationRules"
@change="handleChange"
@ -54,8 +64,13 @@
</template>
</template>
</div>
<div v-show="errorMessage" class="row my-2 form-test-error">
<span class="d-inline-flex mt-0" role="alert">{{ errorMessage }}</span>
<div v-show="errorMessage" class="row my-1 form-test-error">
<span
class="d-inline-flex small mt-0"
role="alert"
:class="[centerErrorMessage ? 'center-error-message' : '']"
>{{ errorMessage }}</span
>
</div>
</div>
</template>
@ -77,8 +92,12 @@ export default {
type: String,
default: "",
},
displayQuestionText: {
type: Boolean,
default: true,
},
modelValue: String,
inputId: String,
customInputId: String,
isDisabled: Boolean,
isRequired: Boolean,
hasIcon: Boolean, // If input has an icon
@ -97,8 +116,12 @@ export default {
includeImageQuestion: Boolean,
imageQuestionSubmitHandler: Function,
maxFileSize: Number,
hideInput: Boolean,
centerErrorMessage: Boolean,
},
setup(props) {
const inputId = !props.customInputId ? `input-${crypto.randomUUID()}` : props.customInputId;
const propsClone = Object.assign({}, props);
const modelValue = propsClone.modelValue;
let initialValue;
@ -120,12 +143,13 @@ export default {
};
const { errorMessage, handleBlur, handleChange, meta, validate, errors } = useField(
props.inputId,
inputId,
props.validationRules,
fieldOptions
);
return {
inputId,
errorMessage,
handleBlur,
handleChange,
@ -175,30 +199,9 @@ export default {
this.$emit("update:modelValue", newValue);
},
},
labelText: {
get: function () {
const noBreakChar = "&NoBreak;";
var questionText = "";
if (this.disableAutoFill) {
var words = this.questionText.toString().split(/[ ]+/);
words.forEach(function (word) {
const position = 1;
word = [
word.toString().slice(0, position),
noBreakChar,
word.toString().slice(position),
].join("");
questionText += `${word} `;
});
questionText = questionText.trimEnd();
} else {
questionText = this.questionText.toString();
}
return questionText;
},
},
},
mounted() {
this.$emit("textboxQuestionEvent.inputIdAssigned", this.inputId);
},
watch: {
async value(newValue) {
@ -216,6 +219,12 @@ export default {
<style lang="scss">
.textbox-question {
.hide-input {
display: none;
}
.center-error-message {
justify-content: center !important;
}
label {
color: $black;
font-weight: 500;

View file

@ -1,10 +1,12 @@
import { shallowMount } from "@vue/test-utils";
import Modal from "./modal";
import { mount } from "@vue/test-utils";
import contentGroupModal from "./content-group-modal";
import crypto from "crypto";
describe("modal.vue", () => {
global.crypto = crypto;
describe("content-group-modal.vue", () => {
it("Should display header text when HeaderText is defined in the CMS", async () => {
// Act
const wrapper = shallowMount(Modal, {
const wrapper = mount(contentGroupModal, {
mixins: [mockMixin],
props: {
cmsWidgetName: "test",
@ -15,8 +17,7 @@ describe("modal.vue", () => {
});
it("Should display subheader text when SubheaderText is defined in the CMS", async () => {
// Act
const wrapper = shallowMount(Modal, {
const wrapper = mount(contentGroupModal, {
mixins: [mockMixin],
props: {
cmsWidgetName: "test",
@ -27,8 +28,7 @@ describe("modal.vue", () => {
});
it("Should insert image url when Image is defined in the CMS", async () => {
// Act
const wrapper = shallowMount(Modal, {
const wrapper = mount(contentGroupModal, {
mixins: [mockMixin],
props: {
cmsWidgetName: "test",
@ -39,8 +39,7 @@ describe("modal.vue", () => {
});
it("Should display body text when BodyText is defined in the CMS", async () => {
// Act
const wrapper = shallowMount(Modal, {
const wrapper = mount(contentGroupModal, {
mixins: [mockMixin],
props: {
cmsWidgetName: "test",

View file

@ -0,0 +1,86 @@
<template>
<modal
:ref="ModalName"
:modalId="ModalName"
:footerButtonText="ModalCloseButtonText"
@footer-button-event="footerButtonClick">
<img :src="ModalImage" class="mw-100 d-flex mx-auto mb-4" alt="" />
<h5 class="mb-4" v-html="ModalHeadline"></h5>
<p class="fw-bold mb-2 subheader-text" v-html="ModalSubheadertext"></p>
<p class="mb-0" v-html="ModalBodyText"></p>
<p
class="my-4 caption modal-sub-body"
v-if="ModalSubBodyText"
v-html="ModalSubBodyText"></p>
</modal>
</template>
<script>
import modal from "@/digital-components/modal/modal";
export default {
name: "content-group-modal",
props: {
cmsWidgetName: String,
},
computed: {
ModalName() {
return this.cmsWidgetName;
},
ModalHeadline() {
return this.getCmsContent(this.cmsWidgetName, "HeaderText");
},
ModalSubheadertext() {
return this.getCmsContent(this.cmsWidgetName, "SubheaderText");
},
ModalBodyText() {
return this.getCmsContent(this.cmsWidgetName, "BodyText");
},
ModalSubBodyText() {
return this.getCmsContent(this.cmsWidgetName, "BodyText2");
},
ModalImage() {
return this.getCmsContent(this.cmsWidgetName, "Image");
},
ModalCloseButtonText() {
return this.getCmsContent(this.cmsWidgetName, "FooterText");
},
},
methods: {
openModal() {
this.$refs[this.ModalName].openModal();
},
footerButtonClick() {
this.$refs[this.ModalName].closeModal();
},
},
components: {
modal,
},
};
</script>
<style lang="scss">
.modal {
&.modal-component {
.modal-dialog {
.modal-content {
.modal-body {
.modal-sub-body {
color: $gray-600;
}
ul {
margin-bottom: 0;
}
p {
&:last-child {
margin-bottom: 0;
}
}
}
}
}
}
}
</style>

View file

@ -34,6 +34,7 @@ import buttonMain from "@/ux-components/button-main/button-main";
export default {
name: "funnelFooter",
emits: ["BackClicked", "ForwardClicked"], // <--- should remove oodles of warnings in dev tools
props: {
isForwardActionDisabled: Boolean,
isBackButtonHidden: { type: Boolean, default: false },

View file

@ -19,7 +19,7 @@
import alert from "@/ux-components/alert/alert";
import eventBus from "@/helpers/event-bus/event-bus";
import { globalEvents } from "@/constants/events";
import menuModal from "@/common-components/funnel-header/menu-modal/menu-modal";
import menuModal from "@/fmg-components/funnel-header/menu-modal/menu-modal";
export default {
name: "funnel-header",

View file

@ -4,8 +4,7 @@
class="menu-button"
type="button"
:class="[isActive ? 'active' : '']"
data-bs-toggle="modal"
data-bs-target="#footerModal"
@click="toggleModal"
aria-label="Hamburger Menu (modal window)">
<div class="bar1"></div>
<div class="bar2"></div>
@ -22,19 +21,6 @@
aria-hidden="true"
v-on="{ 'show.bs.modal': show, 'hide.bs.modal': hide }"
:style="`height: calc(100% - ${currentFooterAndHeaderHeight}px);`">
<div class="menu-modal-container">
<button
class="menu-button"
type="button"
:class="[isActive ? 'active' : '']"
data-bs-toggle="modal"
data-bs-target="#footerModal"
aria-label="Hamburger Menu (modal window)">
<div class="bar1"></div>
<div class="bar2"></div>
<div class="bar3"></div>
</button>
</div>
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header visually-hidden">
@ -66,7 +52,7 @@
<textLink
linkType="navigation"
text="Notice at collection"
href="https://privacyportal-cdn.onetrust.com/dsarwebform/d3b95a93-e22e-4d4d-a806-482052406557/9e371601-eae3-4338-9430-b90b9036022b.html"
href="https://www.safelite.com/ccpa-privacy-policy"
target="_blank" />
</div>
<div class="modal-footer d-flex justify-content-start">
@ -79,6 +65,8 @@
<script>
import textLink from "@/ux-components/text-link/text-link";
import { Modal } from "bootstrap";
export default {
name: "menuModal",
data() {
@ -88,6 +76,19 @@ export default {
};
},
methods: {
toggleModal() {
if (this.isActive) {
this.closeModal();
} else {
this.openModal();
}
},
openModal() {
Modal.getOrCreateInstance(document.getElementById("footerModal")).show();
},
closeModal() {
Modal.getInstance(document.getElementById("footerModal")).hide();
},
show() {
this.currentFooterAndHeaderHeight = this.getFooterInfoBoxHeight() + 72;
this.isActive = true;

View file

@ -1,8 +1,8 @@
<template>
<div class="current_car_info-text">
<div class="funnel-sub-header">
<div
class="d-flex align-items-center justify-content-center container-fluid overflow-hidden">
<h5 class="text-center fw-normal mb-0" :class="headerColor">
<h5 class="text-center fw-normal mb-2" :class="headerColor">
<span>
{{ text }}
</span>
@ -13,8 +13,9 @@
</h5>
</div>
<div
v-if="subText"
class="d-flex align-items-center justify-content-center container-fluid overflow-hidden">
<p class="text-center small fw-normal mb-0">
<p class="text-center small fw-normal sub-text">
<span>
{{ subText }}
</span>
@ -24,13 +25,17 @@
</template>
<script>
import buttonBack from "@/common-components/funnel-sub-header/button-back/button-back";
import buttonBack from "@/fmg-components/funnel-sub-header/button-back/button-back";
export default {
name: "FunnelSubHeader",
props: {
hasBackButton: Boolean,
cmsWidgetName: String,
headerColor: {
type: String,
default: "dark-header",
},
},
components: {
buttonBack,
@ -45,9 +50,6 @@ export default {
backButtonAccessibleText() {
return this.getCmsContent(this.cmsWidgetName, "BackButtonAccessibleText");
},
headerColor() {
return this.subText ? "dark-header" : "light-header";
},
},
methods: {
clickEvent() {
@ -77,4 +79,7 @@ h5 {
p.small {
color: $gray-550;
}
.sub-text {
margin: 0;
}
</style>

View file

@ -1,5 +1,5 @@
// Components
import questionsPageLayout from "@/common-components/layouts/questions-page-layout/questions-page-layout";
import questionsPageLayout from "./questions-page-layout";
// Supporting Files
import { shallowMount } from "@vue/test-utils";

View file

@ -35,13 +35,13 @@
<script>
// Components
import funnelHeader from "@/common-components/funnel-header/funnel-header";
import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner";
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import vehicleBanner from "@/fmg-components/vehicle-banner/vehicle-banner";
import alert from "@/ux-components/alert/alert";
import questionChain from "@/common-components/question-chain/question-chain";
import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header";
import funnelFooter from "@/common-components/funnel-footer/funnel-footer";
import loadingModal from "@/common-components/loading-modal/loading-modal.vue";
import questionChain from "@/digital-components/question-chain/question-chain";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import funnelFooter from "@/fmg-components/funnel-footer/funnel-footer";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
export default {
name: "questions-page",

View file

@ -303,7 +303,7 @@ export function splitCopyOnCMSPlaceHolder(copy) {
* Returns string2 of input following this pattern: {string1:string2,string3}
* @returns string
*/
export function getLinkTargetFromCopy(copy) {
export function getRouterLinkRouteFromCopy(copy) {
// sample input: {routerLink:estimate,provide your VIN}
// first split would return 'estimate,provide your VIN'
// second split would return 'estimate'
@ -314,7 +314,7 @@ export function getLinkTargetFromCopy(copy) {
* Returns string3 of input following this pattern: {string1:string2,string3}
* @returns string
*/
export function getLinkDisplayTextFromCopy(copy) {
export function getRouterLinkDisplayTextFromCopy(copy) {
// sample input: {routerLink:estimate,provide your VIN}
// first split would return 'estimate,provide your VIN'
// second split would return 'provide your VIN'

View file

@ -350,7 +350,7 @@ describe("navigateToHeritageFunnel", () => {
const mockReferralNumber = "2";
const mockCorrelationId = "55";
const mockReferralDate = "2022";
const mockAccountNumber = "167132";
const mockParentAccountNumber = "167132";
const mockSavedSessionId = "xxx-xxx-xxx";
const mockCrmCustomerId = "xxx-xxx-xxx";
@ -358,7 +358,7 @@ describe("navigateToHeritageFunnel", () => {
mockReferralNumber,
mockCorrelationId,
mockReferralDate,
mockAccountNumber,
mockParentAccountNumber,
mockSavedSessionId,
mockCrmCustomerId
);
@ -395,7 +395,7 @@ describe("navigateToHeritageFunnel", () => {
const mockReferralNumber = "2";
const mockCorrelationId = "55";
const mockReferralDate = "2022";
const mockAccountNumber = "167132";
const mockParentAccountNumber = "167132";
const mockSavedSessionId = "xxx-xxx-xxx";
const mockCrmCustomerId = "xxx-xxx-xxx";
@ -403,7 +403,7 @@ describe("navigateToHeritageFunnel", () => {
mockReferralNumber,
mockCorrelationId,
mockReferralDate,
mockAccountNumber,
mockParentAccountNumber,
mockSavedSessionId,
mockCrmCustomerId
);

View file

@ -81,7 +81,7 @@ async function loadSession(
savedSessionId,
referralNumber,
referralDate,
accountNumber,
parentAccountNumber,
referralCorrelationId,
isConceptInsurance
) {
@ -94,8 +94,8 @@ async function loadSession(
savedSessionId: savedSessionId?.toString(),
referralNumber,
referralDate,
accountNumber,
referralCorrelationId,
parentAccountNumber,
isConceptInsurance,
},
false
@ -116,7 +116,7 @@ async function saveSessionHelper() {
referralNumber: savedSessionInfo.data.referralNumber?.toString(),
referralCorrelationId: savedSessionInfo.data.referralCorrelationId,
referralDate: savedSessionInfo.data.referralDate,
accountNumber: savedSessionInfo.data.accountNumber?.toString(),
parentAccountNumber: savedSessionInfo.data.parentAccountNumber?.toString(),
savedSessionId: savedSessionInfo.data.savedSessionId,
crmCustomerId: savedSessionInfo.data.crmCustomerId?.toString(),
eon: savedSessionInfo.data.eon,

View file

@ -142,7 +142,7 @@ describe("saveSession", () => {
const mockReferralNumber = "2";
const mockCorrelationId = "55";
const mockReferralDate = "2022";
const mockAccountNumber = "167132";
const mockParentAccountNumber = "167132";
const mockSavedSessionId = "xxx-xxx-xxx";
const mockCrmCustomerId = "xxx-xxx-xxx";
@ -150,7 +150,7 @@ describe("saveSession", () => {
mockReferralNumber,
mockCorrelationId,
mockReferralDate,
mockAccountNumber,
mockParentAccountNumber,
mockSavedSessionId,
mockCrmCustomerId
);
@ -182,7 +182,7 @@ describe("saveSession", () => {
referralNumber: mockReferralNumber,
referralDate: mockReferralDate,
referralCorrelationId: mockCorrelationId,
accountNumber: mockAccountNumber,
parentAccountNumber: mockParentAccountNumber,
savedSessionId: mockSavedSessionId,
crmCustomerId: mockCrmCustomerId,
},
@ -195,7 +195,7 @@ describe("saveSession", () => {
const mockReferralNumber = 1566818;
const mockReferralDate = "2022-03-15T10:56:24.597";
const mockReferralCorrelationId = "404d2b04-f86e-45c3-b373-127b6217b060";
const mockAccountNumber = "167132";
const mockParentAccountNumber = "167132";
const mockSavedSessionId = "xxx-xxx-xxx";
const mockCrmCustomerId = "xxx-xxx-xxx";
@ -203,7 +203,7 @@ describe("saveSession", () => {
mockReferralNumber,
mockReferralCorrelationId,
mockReferralDate,
mockAccountNumber,
mockParentAccountNumber,
mockSavedSessionId,
mockCrmCustomerId
);

View file

@ -71,8 +71,9 @@ export function getMountOptions(mockData) {
mixins: mockData.mixins,
stubs: { Form },
};
const props = mockData.propsData || {};
return { global };
return { global, props };
}
export function setupMocksForJsFiles(mockData = {}) {
@ -104,7 +105,7 @@ export function getMockOrderInfo(
mockReferralNumber,
mockCorrelationId,
mockReferralDate,
accountNumber = "0",
parentAccountNumber = "0",
savedSessionId,
crmCustomerId
) {
@ -112,7 +113,7 @@ export function getMockOrderInfo(
referralNumber: mockReferralNumber,
referralCorrelationId: mockCorrelationId,
referralDate: mockReferralDate,
accountNumber: accountNumber,
parentAccountNumber: parentAccountNumber,
savedSessionId: savedSessionId,
crmCustomerId: crmCustomerId,
};

View file

@ -81,13 +81,13 @@
<script>
// Components
import funnelHeader from "@/common-components/funnel-header/funnel-header";
import funnelFooter from "@/common-components/funnel-footer/funnel-footer";
import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header";
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import funnelFooter from "@/fmg-components/funnel-footer/funnel-footer";
import vehicleBanner from "@/fmg-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import customerQuestions from "@/layouts/address-lookup/customer-questions/customer-questions";
import alert from "@/ux-components/alert/alert";
import textboxQuestion from "@/common-components/textbox-question/textbox-question";
import textboxQuestion from "@/digital-components/textbox-question/textbox-question";
import { Form, defineRule } from "vee-validate";
import { required, regex } from "@/helpers/validation-rules";

View file

@ -5,9 +5,9 @@ import alert from "@/ux-components/alert/alert";
// Supporting Files
import { mount, shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import { storeActions } from "@/constants/store-actions";
import { storeMutations } from "@/constants/store-mutations";
import store from "@/store";
import { createImportSpecifier } from "typescript";
let autocompleteElement;
describe("address-questions.vue", () => {
@ -19,7 +19,7 @@ describe("address-questions.vue", () => {
});
describe("initial state", () => {
test("only street address field is shown", () => {
test("Should only show the street address field", () => {
// Arrange
const { wrapper } = setupMocks({});
@ -59,36 +59,81 @@ describe("address-questions.vue", () => {
expect(zipCode.exists()).toBe(true);
});
test("Should set this.displayNoMatchWarning to false when it is set to true, if the model if prepopulated", async () => {
test("Should render apartmentNumberOrBusinessName textbox-question when captureApartmentNumberOrBusinessName = true", async () => {
// Arrange
const newAddressModel = {
streetAddress: "foo",
city: "foo",
state: "foo",
zipCode: "55555",
};
const wrapper = shallowMount(addressQuestions, {
propsData: {
modelValue: newAddressModel,
const { wrapper } = setupMocks({
props: {
modelValue: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
captureApartmentNumberOrBusinessName: true,
},
});
await wrapper.setData({
displayNoMatchWarning: true,
showAddressFields: true,
});
expect(wrapper.vm.displayNoMatchWarning).toBeTruthy();
// Act
wrapper.vm.$options.watch.addressModel.handler.call(
wrapper.vm,
wrapper.vm.addressModel
);
const streetAddress = wrapper.findComponent({ ref: "autocomplete" });
const apartmentNumberOrBusinessName = wrapper.findComponent({
ref: "apartmentNumberOrBusinessName",
});
const city = wrapper.findComponent({ ref: "city" });
const state = wrapper.findComponent({ ref: "state" });
const zipCode = wrapper.findComponent({ ref: "zipCode" });
// Assert
expect(wrapper.vm.displayNoMatchWarning).toBeFalsy();
expect(streetAddress.exists()).toBe(true);
expect(apartmentNumberOrBusinessName.exists()).toBe(true);
expect(apartmentNumberOrBusinessName.isVisible()).toBe(true);
expect(city.exists()).toBe(true);
expect(state.exists()).toBe(true);
expect(zipCode.exists()).toBe(true);
});
test("Should it set this.showAddressFields to true when the model is prepopulated", async () => {
test("Should *not* render apartmentNumberOrBusinessName textbox-question when captureApartmentNumberOrBusinessName = false", async () => {
// Arrange
const { wrapper } = setupMocks({
props: {
modelValue: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
captureApartmentNumberOrBusinessName: false,
},
});
await wrapper.setData({
showAddressFields: true,
});
// Act
const streetAddress = wrapper.findComponent({ ref: "autocomplete" });
const apartmentNumberOrBusinessName = wrapper.findComponent({
ref: "apartmentNumberOrBusinessName",
});
const city = wrapper.findComponent({ ref: "city" });
const state = wrapper.findComponent({ ref: "state" });
const zipCode = wrapper.findComponent({ ref: "zipCode" });
// Assert
expect(streetAddress.exists()).toBe(true);
expect(apartmentNumberOrBusinessName.exists()).toBe(true);
expect(apartmentNumberOrBusinessName.isVisible()).toBe(false);
expect(city.exists()).toBe(true);
expect(state.exists()).toBe(true);
expect(zipCode.exists()).toBe(true);
});
test("Should set this.showAddressFields to true when the model is prepopulated", async () => {
// Arrange
// Act
const newAddressModel = {
@ -239,81 +284,13 @@ describe("address-questions.vue", () => {
);
// Assert
const addressModel = wrapper.vm.addressModel;
expect(addressModel.streetAddress).toEqual("1234 Test Road");
expect(addressModel.city).toEqual("Columbus");
expect(addressModel.state).toEqual("OH");
expect(addressModel.zipCode).toEqual("43215");
});
test("street address is entered, user chooses good result from autocomplete results => alerts are cleared", async () => {
// Arrange
const { wrapper } = setupMocks({});
await wrapper.setData({
addressModel: {
streetAddress: "123 Test Street",
},
displayVerificationWarning: true,
displayNoMatchWarning: true,
wrapper.vm.$nextTick(function () {
const addressModel = wrapper.vm.addressModel;
expect(addressModel.streetAddress).toEqual("1234 Test Road");
expect(addressModel.city).toEqual("Columbus");
expect(addressModel.state).toEqual("OH");
expect(addressModel.zipCode).toEqual("43215");
});
let alerts = wrapper.findAllComponents(alert);
alerts.forEach((alert) => expect(alert.isVisible()).toBeTruthy());
const selectedPlace = {
address_components: [
{
long_name: "1234",
short_name: "1234",
types: ["street_number"],
},
{
long_name: "Test Road",
short_name: "Test Road",
types: ["route"],
},
{
long_name: "East Columbus",
short_name: "Columbus",
types: ["neighborhood", "political"],
},
{
long_name: "Columbus",
short_name: "Columbus",
types: ["locality", "political"],
},
{
long_name: "Franklin County",
short_name: "Franklin County",
types: ["administrative_area_level_2", "political"],
},
{
long_name: "Ohio",
short_name: "OH",
types: ["administrative_area_level_1", "political"],
},
{
long_name: "United States",
short_name: "US",
types: ["country", "political"],
},
{
long_name: "43215",
short_name: "43215",
types: ["postal_code"],
},
],
};
// Act
autocompleteElement.dispatchEvent(
new CustomEvent("place_changed", { detail: selectedPlace })
);
await wrapper.vm.$nextTick();
// Assert
alerts = wrapper.findAllComponents(alert);
alerts.forEach((alert) => expect(alert.exists()).toBeFalsy());
});
test("street address is entered, but user clicks away => first result is selected and other fields are filled in", async () => {
@ -403,42 +380,6 @@ describe("address-questions.vue", () => {
});
describe("alerts", () => {
const places = [null, { address_components: null }, undefined, {}];
test.each(places)(
"selected place/place properties is null => display verification alert",
async (place) => {
// Arrange
const { wrapper } = setupMocks({});
await wrapper.setData({
addressModel: {
streetAddress: "123 Test Street",
},
displayVerificationWarning: true,
displayNoMatchWarning: true,
});
let alerts = wrapper.findAllComponents(alert);
alerts.forEach((alert) => expect(alert.isVisible()).toBeTruthy());
const selectedPlace = place;
// Act
autocompleteElement.dispatchEvent(
new CustomEvent("place_changed", { detail: selectedPlace })
);
await wrapper.vm.$nextTick();
// Assert
const verificationAlert = wrapper.findComponent({
ref: "alertVerificationWarning",
});
expect(verificationAlert.exists()).toBe(true);
expect(verificationAlert.isVisible()).toBe(true);
const noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
expect(noMatchAlert.exists()).toBe(false);
}
);
test("user enters address that yields no autocomplete results => show noMatch alert", async () => {
// Arrange
let changeEventCallbackFunction;
@ -513,78 +454,111 @@ describe("address-questions.vue", () => {
});
describe("noMatch alert is cleared on address change", () => {
test("user sees noMatch warning and enters city => noMatch warning is removed", async () => {
test("user sees noMatch warning and modifies street address => noMatch warning is removed", async () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.setData({
displayNoMatchWarning: true,
const { wrapper } = setupMocks({
props: {
modelValue: {
streetAddress: "LS",
city: "",
state: "",
zipCode: "",
},
},
});
await wrapper.vm.$nextTick();
let noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
expect(noMatchAlert.exists()).toBeTruthy();
expect(noMatchAlert.isVisible()).toBeTruthy();
// Initial condition to display noMatch warning
await wrapper.setData({
matchFound: false,
});
// Act
wrapper.vm.$options.watch.addressModel.handler.call(wrapper.vm, {
city: "Somewhere",
});
wrapper.vm.addressModel.streetAddress = "LJS";
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.vm.displayNoMatchWarning).toBeFalsy();
noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
let noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
expect(noMatchAlert.exists()).toBeFalsy();
});
test("user sees noMatch warning and enters city => noMatch warning is removed", async () => {
// Arrange
const { wrapper } = setupMocks({
props: {
modelValue: {
streetAddress: "",
city: "LS",
state: "",
zipCode: "",
},
},
});
// Initial condition to display noMatch warning
await wrapper.setData({
matchFound: false,
});
// Act
wrapper.vm.addressModel.city = "LJS";
await wrapper.vm.$nextTick();
// Assert
let noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
expect(noMatchAlert.exists()).toBeFalsy();
});
test("user sees noMatch warning and enters state => noMatch warning is removed", async () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.setData({
displayNoMatchWarning: true,
const { wrapper } = setupMocks({
props: {
modelValue: {
streetAddress: "",
city: "",
state: "LS",
zipCode: "",
},
},
});
await wrapper.vm.$nextTick();
let noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
expect(noMatchAlert.exists()).toBeTruthy();
expect(noMatchAlert.isVisible()).toBeTruthy();
// Initial condition to display noMatch warning
await wrapper.setData({
matchFound: false,
});
// Act
wrapper.vm.$options.watch.addressModel.handler.call(wrapper.vm, {
state: "KO",
});
wrapper.vm.addressModel.state = "LJS";
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.vm.displayNoMatchWarning).toBeFalsy();
noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
let noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
expect(noMatchAlert.exists()).toBeFalsy();
});
test("user sees noMatch warning and enters zip code => noMatch warning is removed", async () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.setData({
displayNoMatchWarning: true,
const { wrapper } = setupMocks({
props: {
modelValue: {
streetAddress: "",
city: "",
state: "",
zipCode: "LS",
},
},
});
await wrapper.vm.$nextTick();
let noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
expect(noMatchAlert.exists()).toBeTruthy();
expect(noMatchAlert.isVisible()).toBeTruthy();
// Initial condition to display noMatch warning
await wrapper.setData({
matchFound: false,
});
// Act
wrapper.vm.$options.watch.addressModel.handler.call(wrapper.vm, {
zipCode: "12345",
});
wrapper.vm.addressModel.zipCode = "LJS";
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.vm.displayNoMatchWarning).toBeFalsy();
noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
let noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
expect(noMatchAlert.exists()).toBeFalsy();
});
});
@ -645,6 +619,7 @@ function setupMocks({
const wrapper = isShallowMount
? shallowMount(addressQuestions, resultingMountOptions)
: mount(addressQuestions, resultingMountOptions);
document.querySelector = jest.fn().mockImplementation((query) => {
let result = null;
if (query == ".pac-container") result = document.createElement("div");

View file

@ -1,29 +1,42 @@
<template>
<div role="application">
<div class="row mt-2 mb-4">
<div class="address-questions" role="application">
<div class="row mb-4">
<div class="col">
<textboxQuestion
id="streetAddressField"
cmsWidgetName="StreetAddressQuestionWidget"
v-model="addressModel.streetAddress"
ref="autocomplete"
inputId="autocomplete"
customInputId="autocomplete"
placeholderText="Search"
aria-haspopup=""
hasIcon
disableAutoFill
validationRules="street-address-required" />
validationRules="street-address-required"
@keydown.enter.prevent />
</div>
</div>
<transition name="fade" mode="out-in">
<div
class="row mb-4"
v-show="showAddressFields && showApartmentNumberOrBusinessNameField"
aria-live="polite">
<div class="col">
<textboxQuestion
customInputId="apartmentNumberOrBusinessName"
cmsWidgetName="ApartmentNumberOrBusinessNameQuestionWidget"
v-model="addressModel.apartmentNumberOrBusinessName"
ref="apartmentNumberOrBusinessName" />
</div>
</div>
</transition>
<transition name="fade" mode="out-in">
<div class="row mb-4" v-show="showAddressFields" aria-live="polite">
<div class="col">
<textboxQuestion
customInputId="city"
cmsWidgetName="CityQuestionWidget"
v-model="addressModel.city"
ref="city"
inputId="cbf28188fdf2436688fd735915f7ee56"
disableAutoFill
validationRules="city-required" />
</div>
</div>
@ -32,22 +45,20 @@
<div class="row mb-4" v-show="showAddressFields" aria-live="polite">
<div class="col">
<dropdownQuestion
customInputId="state"
cmsWidgetName="StateQuestionWidget"
v-model="addressModel.state"
ref="state"
inputId="8fdf9dc2e13e430eb57529499dceb3eb"
:options="stateOptions"
disableAutoFill
validationRules="state-required" />
</div>
<div class="col">
<textboxQuestion
customInputId="zipCode"
cmsWidgetName="ZipQuestionWidget"
v-model="addressModel.zipCode"
ref="zipCode"
inputId="01a9a1c2de0b4c9da8e023c9ae3be498"
mask="#####"
disableAutoFill
validationRules="zip-code-required|zip-code-format" />
</div>
</div>
@ -70,8 +81,8 @@
</template>
<script>
import textboxQuestion from "@/common-components/textbox-question/textbox-question";
import dropdownQuestion from "@/common-components/dropdown-question/dropdown-question";
import textboxQuestion from "@/digital-components/textbox-question/textbox-question";
import dropdownQuestion from "@/digital-components/dropdown-question/dropdown-question";
import alert from "@/ux-components/alert/alert";
import { applicationConfig } from "@/constants/application-config.js";
import { defineRule } from "vee-validate";
@ -93,12 +104,17 @@ export default {
type: Object,
default: () => ({
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
}),
},
validationRules: String,
captureApartmentNumberOrBusinessName: {
type: Boolean,
default: false,
},
},
data() {
return {
@ -109,6 +125,7 @@ export default {
alertCopyVerificationWarning: "",
alertHeadlineNoMatchWarning: "",
alertCopyNoMatchWarning: "",
matchFound: null, // null = no attempted match, true = match was found, false = match was not found
};
},
computed: {
@ -177,9 +194,15 @@ export default {
this.$emit("update:modelValue", newValue);
},
},
showApartmentNumberOrBusinessNameField: {
get: function () {
return this.captureApartmentNumberOrBusinessName;
},
},
},
methods: {
setupAddressLookup() {
this.showAddressFields = false;
if (
this.addressModel.streetAddress &&
this.addressModel.city &&
@ -196,7 +219,7 @@ export default {
const apiKey = applicationConfig.GOOGLE_PLACES_API_KEY;
this.$loadScript(
`https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`
`https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places&callback=Function.prototype`
)
.then(() => {
// Script is loaded, initialize the autocomplete textbox
@ -213,11 +236,11 @@ export default {
fillInAddress
);
// Wrapping the addressField1 element in the Google Address Autocomplete object
// will cause "autocomplete='off'" which Chrome completely ignores. This event
// handler will set the value to something arbitrary so autofill doesn't work.
// https://stackoverflow.com/a/30976223
addressField1.addEventListener("focus", () => {
// Wrapping the addressField1 element in the Google Address Autocomplete object
// will cause "autocomplete='off'" which Chrome completely ignores. This event
// handler will set the value to something arbitrary so autofill doesn't work.
// https://stackoverflow.com/a/30976223
addressField1.setAttribute("autocomplete", "do-not-autofill");
// Make place results box stick to the input on scroll
@ -229,87 +252,116 @@ export default {
}
});
addressField1.addEventListener("change", () => {
const hover = document.querySelector(".pac-container .pac-item:hover");
// if an item has been clicked, do nothing, otherwise get first solution and use Geocoder to get the place
if (hover === null) {
const item = document.querySelector(".pac-container .pac-item");
if (item != null) {
const firstResult = item.textContent;
const geocoder = new window.google.maps.Geocoder();
geocoder.geocode(
{
address: firstResult,
},
function (results, status) {
if (status === window.google.maps.GeocoderStatus.OK) {
fillInAddress(results[0]);
self.displayVerificationWarning = true;
self.displayNoMatchWarning = false;
}
}
);
addressField1.addEventListener("keydown", (e) => {
const autocomplete = document.getElementById("autocomplete");
const event = new Event("place_changed");
if (e.code === "Enter" || e.code === "NumpadEnter" || e.code === "Tab") {
const selectedItem = document.querySelector(
".pac-container .pac-item-selected"
);
if (selectedItem !== null) {
// Fill-in the address using selected item in the list.
autocomplete.dispatchEvent(event);
//fillInAddress(selectedItem.textContent);
} else {
self.addressModel.city = "";
self.addressModel.state = "";
self.addressModel.zipCode = "";
self.showAddressFields = true;
self.displayVerificationWarning = false;
self.displayNoMatchWarning = true;
// Fill-in the address using first item in the list.
fillInAddressUsingFirstItem();
}
} else {
return;
}
});
addressField1.addEventListener("change", () => {
// If a match has been previously attempted then do nothing
if (self.matchFound !== null) {
return;
}
// Get the address that the user clicked on (if any)
const clickedAddress = document.querySelector(
".pac-container .pac-item:hover"
);
// If the Street Address field changed without clicking (i.e. by pressing Tab, or clicking outside the field)
if (clickedAddress === null) {
// Fill-in the address using first item in the list.
fillInAddressUsingFirstItem();
}
});
function fillInAddressUsingFirstItem() {
// Fill-in the address using first item in the list.
const item = document.querySelector(".pac-container .pac-item");
if (item != null) {
const firstResult = item.textContent;
const geocoder = new window.google.maps.Geocoder();
geocoder.geocode(
{
address: firstResult,
},
function (results, status) {
if (status === window.google.maps.GeocoderStatus.OK) {
fillInAddress(results[0]);
self.displayVerificationWarning = true;
}
}
);
} else {
self.matchFound = false;
}
}
function fillInAddress(place) {
if (!place) {
place = autocomplete.getPlace();
}
if (place && place.address_components) {
self.addressModel.streetAddress = "";
self.showAddressFields = true;
self.matchFound = true;
for (const component of place.address_components) {
const componentType = component.types[0];
self.$nextTick(function () {
self.showAddressFields = true;
switch (componentType) {
case "street_number": {
self.addressModel.streetAddress = component.long_name;
break;
}
case "route": {
self.addressModel.streetAddress +=
" " + component.short_name;
break;
}
case "locality": {
self.addressModel.city = component.long_name;
break;
}
case "administrative_area_level_1": {
self.addressModel.state = component.short_name;
break;
}
case "postal_code": {
self.addressModel.zipCode = component.long_name;
break;
for (const component of place.address_components) {
const componentType = component.types[0];
switch (componentType) {
case "street_number": {
self.addressModel.streetAddress = component.long_name;
break;
}
case "route": {
self.addressModel.streetAddress +=
" " + component.short_name;
break;
}
case "locality": {
self.addressModel.city = component.long_name;
break;
}
case "administrative_area_level_1": {
self.addressModel.state = component.short_name;
break;
}
case "postal_code": {
self.addressModel.zipCode = component.long_name;
break;
}
}
}
}
self.displayVerificationWarning = false;
self.displayNoMatchWarning = false;
} else {
self.displayVerificationWarning = true;
self.displayNoMatchWarning = false;
// after showing the address fields, disable the address autocomplete
window.google.maps.event.removeListener(autocompleteListener);
window.google.maps.event.clearInstanceListeners(autocomplete);
addressField1.onchange = null;
const pacContainer = document.querySelector(".pac-container");
if (pacContainer) {
pacContainer.remove();
}
});
}
// after showing the address fields, disable the address autocomplete
window.google.maps.event.removeListener(autocompleteListener);
window.google.maps.event.clearInstanceListeners(autocomplete);
addressField1.onchange = null;
const pacContainer = document.querySelector(".pac-container");
pacContainer.remove();
}
})
.catch(() => {
@ -317,19 +369,49 @@ export default {
console.log("Unable to load Google Places API script");
});
},
resetAlerts() {
this.displayVerificationWarning = false;
},
},
mounted() {
this.setupAddressLookup();
},
watch: {
addressModel: {
matchFound: {
handler(newValue) {
// Clear no match warning on address change
if (newValue.city || newValue.state || newValue.zipCode) {
if (newValue === null) {
this.displayNoMatchWarning = false;
this.displayVerificationWarning = false;
this.unwatchAddress();
return;
}
if (!newValue) {
this.displayNoMatchWarning = true;
this.addressModel.city = "";
this.addressModel.state = "";
this.addressModel.zipCode = "";
this.showAddressFields = true;
this.displayVerificationWarning = false;
// Only deep watch the Address Model after a failed match
this.unwatchAddress = this.$watch(
"addressModel",
() => {
// When the address model changes reset to "no attempted match"
this.matchFound = null;
this.$nextTick();
},
{ deep: true, flush: "post" }
);
}
},
deep: true,
},
modelValue: {
handler() {
this.setupAddressLookup();
},
},
},
components: {
@ -341,6 +423,10 @@ export default {
</script>
<style lang="scss">
.address-questions {
margin-top: 0.5rem;
}
#streetAddressField {
position: relative;

View file

@ -7,7 +7,6 @@
v-model="customerModel.firstName"
ref="firstName"
inputId="08497a2efd9a4a73a70360ab47b4838d"
disableAutoFill
validationRules="first-name-required" />
</div>
</div>
@ -18,7 +17,6 @@
v-model="customerModel.lastName"
ref="lastName"
inputId="0030e56a57e74a4ab92de7fb8e97fec5"
disableAutoFill
validationRules="last-name-required" />
</div>
</div>
@ -29,7 +27,6 @@
v-model="customerModel.emailAddress"
ref="emailAddress"
inputId="00450a91b8964a768ce3992e6feb890f"
disableAutoFill
validationRules="email-address-required|email-address-format" />
</div>
</div>
@ -42,12 +39,12 @@
<script>
import addressQuestions from "@/layouts/address-lookup/customer-questions/address-questions/address-questions";
import textboxQuestion from "@/common-components/textbox-question/textbox-question";
import textboxQuestion from "@/digital-components/textbox-question/textbox-question";
import { defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { regex } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
import textBlock from "@/common-components/text-block/text-block";
import textBlock from "@/digital-components/text-block/text-block";
// DEFINE VALIDATION RULES
defineRule("first-name-required", required(errorMessages.FIRST_NAME_REQUIRED));
@ -56,7 +53,7 @@ defineRule("email-address-required", required(errorMessages.EMAIL_ADDRESS_REQUIR
defineRule(
"email-address-format",
regex(
/^([a-zA-Z0-9_\-.+]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,})$/,
/^([a-zA-Z0-9_\-.+]+)@([a-zA-Z0-9-]+)\.([a-zA-Z]{2,})$/,
errorMessages.EMAIL_ADDRESS_FORMAT
)
);

View file

@ -21,7 +21,7 @@
</template>
<script>
import buttonQuestion from "@/common-components/button-question/button-question";
import buttonQuestion from "@/digital-components/button-question/button-question";
import alert from "@/ux-components/alert/alert";
// Supporting files

View file

@ -25,10 +25,10 @@
<span v-if="doesCopyContainRouterLink(copy)" class="text-body">
<router-link
:to="{
query: { fmgPage: `${getLinkTargetFromCopy(copy)}` },
query: { fmgPage: `${getRouterLinkRouteFromCopy(copy)}` },
name: 'root',
}"
>{{ getLinkDisplayTextFromCopy(copy) }}</router-link
>{{ getRouterLinkDisplayTextFromCopy(copy) }}</router-link
>
</span>
<span v-else class="m-0 text-body" v-html="copy"></span>
@ -46,10 +46,10 @@
<script>
// Components
import funnelHeader from "@/common-components/funnel-header/funnel-header";
import funnelFooter from "@/common-components/funnel-footer/funnel-footer";
import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header";
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import funnelFooter from "@/fmg-components/funnel-footer/funnel-footer";
import vehicleBanner from "@/fmg-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import alert from "@/ux-components/alert/alert";
import addressVehiclesQuestion from "@/layouts/address-vehicles/address-vehicles-question/address-vehicles-question";
@ -66,8 +66,8 @@ import { isGlassAvailableForCarId } from "@/helpers/damage-helper";
import {
doesCopyContainRouterLink,
splitCopyOnCMSPlaceHolder,
getLinkTargetFromCopy,
getLinkDisplayTextFromCopy,
getRouterLinkRouteFromCopy,
getRouterLinkDisplayTextFromCopy,
} from "@/helpers/cms-content-helper";
import { routerParams } from "@/router/router-constants/router-params";
import vinPagesMixin from "@/mixins/vin-pages-mixin";
@ -151,8 +151,8 @@ export default {
methods: {
doesCopyContainRouterLink,
splitCopyOnCMSPlaceHolder,
getLinkTargetFromCopy,
getLinkDisplayTextFromCopy,
getRouterLinkRouteFromCopy,
getRouterLinkDisplayTextFromCopy,
arePagePrerequisitesValid() {
if (
store.getters.order.vehicle.carId &&
@ -247,7 +247,7 @@ export default {
line-height: 1.4;
a {
text-underline-offset: 0.2em;
text-underline-offset: 4px; //Per Devyn. This can't be documented in Figma so there is a comment with the Prototype mocks on the Quote page in Figma
line-height: inherit;
}
}

View file

@ -16,7 +16,7 @@
<script>
// Components
import questionsPageLayout from "@/common-components/layouts/questions-page-layout/questions-page-layout";
import questionsPageLayout from "@/fmg-components/layouts/questions-page-layout/questions-page-layout";
// Supporting Files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";

View file

@ -0,0 +1,19 @@
import demoDatePicker from "./demo-date-picker";
// Supporting Files
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
describe("demo-date-picker.vue", () => {
test.only("test TK...", () => {});
});
function setupMocks({ mountOptionsMockData = {} }) {
const mountOptions = getMountOptions({
...mountOptionsMockData,
});
const wrapper = shallowMount(demoDatePicker, mountOptions);
return { wrapper };
}

View file

@ -0,0 +1,83 @@
<template>
<div class="page-container-grouped-styles">
<funnelHeader cmsWidgetName="FunnelHeaderWidget" />
<div class="select-car">
<div class="select-car-form rounded text-center">
<div class="fade-on-route-transition">
<date-picker
selectableDates="custom"
v-model="selectedDate"
:customSelectableDatesCallback="getAvailableDates" />
<!-- EXAMPLE THAT OVERRIDES TODAY BY PASSING STRING --
<date-picker
selectableDates="custom"
v-model="selectedDate"
:customSelectableDatesCallback="getAvailableDates"
todayOverrideDateString="2022-12-30T03:00:00" /> -->
</div>
</div>
</div>
</div>
</template>
<script>
// Components
import datePicker from "@/digital-components/date-picker/date-picker";
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import vehicleBanner from "@/fmg-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
// Supporting files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
import { storeActions } from "@/constants/store-actions";
import store from "@/store";
import baseMixin from "@/mixins/base-mixin";
export default {
name: "demo-date-picker",
data() {
return {
selectedDate: null,
// selectedDate: { // USE THIS FORMAT FOR A PRE-SELECTED DATE ON LOAD
// year: 2023,
// month: 3, // use 1-based index for months
// date: 21,
// },
mockSelectableDatesData: [
{ year: 2023, month: 3, date: 4 },
{ year: 2023, month: 3, date: 5 },
{ year: 2023, month: 3, date: 22 },
{ year: 2023, month: 4, date: 13 },
{ year: 2023, month: 4, date: 14 },
{ year: 2023, month: 4, date: 26 },
{ year: 2023, month: 5, date: 21 },
{ year: 2023, month: 5, date: 23 },
{ year: 2023, month: 5, date: 25 },
],
};
},
computed: {
todayDate() {
const today = new Date();
return today.toDateString();
},
},
methods: {
arePagePrerequisitesValid() {
return true;
},
getAvailableDates(startDate, endDate) {
this.mockSelectableDatesData.push(endDate);
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// temporary test method that adds endDate to list of selectable dates
return this.mockSelectableDatesData;
},
},
components: {
datePicker,
funnelHeader,
},
};
</script>

View file

@ -4,7 +4,7 @@
<loadingModal ref="loadingModal" />
<funnelHeader cmsWidgetName="FunnelHeaderWidget" />
<vehicleBanner cmsWidgetName="VehicleBannerWidget" />
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" />
<funnelSubHeader class="pb-4" cmsWidgetName="FunnelSubHeaderWidget" />
<div class="fade-on-route-transition sub-container make-tall">
<div v-if="!skipVin">
<alert
@ -34,7 +34,6 @@
inputId="serviceZipCode"
mask="#####"
isRequired
disableAutoFill
validationRules="zip-required|zip-format" />
</div>
</div>
@ -45,7 +44,6 @@
v-model="emailAddress"
inputId="emailAddress"
isRequired
disableAutoFill
validationRules="email-address-required|email-address-format" />
</div>
</div>
@ -84,15 +82,15 @@
<script>
// Components
import funnelHeader from "@/common-components/funnel-header/funnel-header";
import funnelFooter from "@/common-components/funnel-footer/funnel-footer";
import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header";
import buttonQuestion from "@/common-components/button-question/button-question";
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import funnelFooter from "@/fmg-components/funnel-footer/funnel-footer";
import vehicleBanner from "@/fmg-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import buttonQuestion from "@/digital-components/button-question/button-question";
import alert from "@/ux-components/alert/alert";
import textboxQuestion from "@/common-components/textbox-question/textbox-question";
import textBlock from "@/common-components/text-block/text-block";
import loadingModal from "@/common-components/loading-modal/loading-modal.vue";
import textboxQuestion from "@/digital-components/textbox-question/textbox-question";
import textBlock from "@/digital-components/text-block/text-block";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
//Supporting Files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
@ -122,7 +120,7 @@ defineRule("email-address-required", required(errorMessages.EMAIL_ADDRESS_REQUIR
defineRule(
"email-address-format",
regex(
/^([a-zA-Z0-9_\-.+]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,})$/,
/^([a-zA-Z0-9_\-.+]+)@([a-zA-Z0-9-]+)\.([a-zA-Z]{2,})$/,
errorMessages.EMAIL_ADDRESS_FORMAT
)
);
@ -252,9 +250,28 @@ export default {
} else if (this.$store.getters.order.referralNumber?.length === 6) {
await this.navigateForwardWithSingleCarMatch();
} else if (this.isRepair) {
const supportingItemsPromise = await this.dispatchStoreAction(
storeActions.GET_SUPPORTING_ITEMS
);
const promiseResultMap = [
{
resultKey: "supportingItems",
promise: supportingItemsPromise,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
this.dispatchStoreAction(
this.storeActions.SAVE_SUPPORTING_ITEMS,
resultMap.supportingItems,
false
);
// call saveSession here - navigateWithSaving saves too late in the flow
await saveSession({});
return this.$router.navigateWithoutSaving(
return this.$router.navigateWithSaving(
this.navigationScenarios.CLICKED_FORWARD_WITH_NO_QUESTIONS,
this.$route
);
@ -334,7 +351,4 @@ export default {
font-weight: 500;
color: $black;
}
div .current_car_info-text {
padding-bottom: 16px;
}
</style>

View file

@ -91,13 +91,13 @@
<script>
// Components
import funnelHeader from "@/common-components/funnel-header/funnel-header";
import funnelFooter from "@/common-components/funnel-footer/funnel-footer";
import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header";
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import funnelFooter from "@/fmg-components/funnel-footer/funnel-footer";
import vehicleBanner from "@/fmg-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import alert from "@/ux-components/alert/alert";
import textboxQuestion from "@/common-components/textbox-question/textbox-question";
import textBlock from "@/common-components/text-block/text-block";
import textboxQuestion from "@/digital-components/textbox-question/textbox-question";
import textBlock from "@/digital-components/text-block/text-block";
// Supporting files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
@ -120,7 +120,7 @@ defineRule("email-address-required", required(errorMessages.EMAIL_ADDRESS_REQUIR
defineRule(
"email-address-format",
regex(
/^([a-zA-Z0-9_\-.+]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,})$/,
/^([a-zA-Z0-9_\-.+]+)@([a-zA-Z0-9-]+)\.([a-zA-Z]{2,})$/,
errorMessages.EMAIL_ADDRESS_FORMAT
)
);

View file

@ -16,7 +16,7 @@
<script>
// Components
import questionsPageLayout from "@/common-components/layouts/questions-page-layout/questions-page-layout";
import questionsPageLayout from "@/fmg-components/layouts/questions-page-layout/questions-page-layout";
// Supporting Files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";

View file

@ -16,7 +16,7 @@
<script>
// Components
import questionsPageLayout from "@/common-components/layouts/questions-page-layout/questions-page-layout";
import questionsPageLayout from "@/fmg-components/layouts/questions-page-layout/questions-page-layout";
// Supporting Files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";

View file

@ -11,7 +11,7 @@
</template>
<script>
import buttonQuestion from "@/common-components/button-question/button-question";
import buttonQuestion from "@/digital-components/button-question/button-question";
const cashAnswer = "CashAnswer";
const insuranceAnswer = "InsuranceAnswer";

View file

@ -0,0 +1,427 @@
import { shallowMount, flushPromises } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import { applicationConfig } from "@/constants/application-config";
import { storeActions } from "@/constants/store-actions";
import quote from "@/layouts/quote/quote.vue";
import store from "@/store";
import * as navigateToHeritage from "@/helpers/heritage-integration/navigation-helper";
import { nextTick } from "vue";
jest.mock("@/store", () => ({
commit: jest.fn(),
dispatch: jest.fn(),
}));
// Mock fetchCmsContentForPage
jest.mock("@/helpers/cms-content-helper", () => ({
fetchCmsContentForPage: () => Promise.resolve("content"),
}));
jest.mock("@/helpers/heritage-integration/navigation-helper", () => ({
navigateToHeritageFunnel: jest.fn(),
}));
jest.mock(
"@/store",
() => {
return {};
},
{ virtual: true }
);
// START beforeRouteEnter mock arranging //
jest.mock("@/mixins/base-mixin", () => ({
methods: {
dispatchStoreAction(action, items, encode) {
if (action === mockPriceOrderStoreAction) return items;
else return mockStoreActionResults[action];
},
getTierOnePackagePrice() {
return mockTierOnePrice;
},
filterOutFees() {
return null;
},
},
}));
let mockTierOnePrice = 501;
const mockPriceOrderStoreAction = storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA;
const mockStoreActionResults = {};
mockStoreActionResults[storeActions.GET_WIPERS] = Promise.resolve([
{ partNumber: "SBB24", partType: "FRONT WIPER" },
]);
mockStoreActionResults[storeActions.GET_RAIN_DEFENSE] = Promise.resolve({
partNumber: "RAIN DEFENSE",
partType: "RAIN DEFENSE",
});
mockStoreActionResults[storeActions.GET_SUPPORTING_ITEMS] = Promise.resolve([
{ partNumber: "WSREPAIR", partType: "WSREPAIR" },
]);
// END beforeRouteEnter mock arranging
store.getters = {
order: {
payment: {
insuranceCoverage: {},
isInsurance: false,
parentAccountNumber: applicationConfig.CASH_PARENT_ACCOUNT_NUMBER,
},
},
};
afterEach(() => {
// reset store after each test
store.getters = {
order: {
payment: {
parentAccountNumber: applicationConfig.CASH_PARENT_ACCOUNT_NUMBER,
},
},
payment: {
insuranceCoverage: {},
isInsurance: false,
},
};
});
describe("quote.vue", () => {
test("IsInsurance false should navigateWithSaving", async () => {
//Arrange
const { wrapper } = setupMocks({
customMountOptions: {
router: {
navigateWithSaving: jest.fn(),
},
route: { quote },
},
});
store.getters.payment = {
insuranceCoverage: {},
isInsurance: false,
};
wrapper.vm.dispatchStoreAction = jest.fn(() => {
return {
data: [],
};
});
wrapper.vm.pricedGlassParts = [];
//Act
await wrapper.vm.forwardButtonAction();
//Assert
expect(wrapper.vm.$router.navigateWithSaving).toHaveBeenCalled();
});
test("IsInsurance true should navigateToHeritageFunnel", async () => {
//Arrange
const { wrapper } = setupMocks({
customMountOptions: {
router: {
navigateWithSaving: jest.fn(),
},
route: { quote },
},
});
store.getters.payment = {
insuranceCoverage: {},
isInsurance: true,
};
wrapper.vm.dispatchStoreAction = jest.fn(() => {
return {
data: [],
};
});
wrapper.vm.pricedGlassParts = [];
//Act
await wrapper.vm.forwardButtonAction();
//Assert
expect(navigateToHeritage.navigateToHeritageFunnel).toHaveBeenCalled();
});
test("should pass arePagePrerequisitesValid with a repair order", () => {
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
serviceLocation: {
zipCode: "12345",
zipCodeCtu: "value",
},
damage: {
isRepair: true,
},
referralNumber: "1234567",
},
};
let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
expect(arePagePrerequisitesValid).toBe(true);
});
test("should pass arePagePrerequisitesValid with a replace order", () => {
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
serviceLocation: {
zipCode: "12345",
zipCodeCtu: "value",
},
damage: {
isRepair: false,
},
referralNumber: "1234567",
lineItems: {
glassParts: ["item", "item2"],
},
},
};
let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
expect(arePagePrerequisitesValid).toBe(true);
});
test("should fail arePagePrerequisitesValid without glass parts or flagged as repair", () => {
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
serviceLocation: {
zipCode: "12345",
zipCodeCtu: "value",
},
damage: {
isRepair: false,
},
referralNumber: "1234567",
},
};
let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
expect(arePagePrerequisitesValid).toBe(false);
});
test("should have non-null values for necessary data members after 'beforeRouteEnter'", async () => {
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
lineItems: {
glassParts: ["item", "item2"],
},
},
};
//mock this to avoid needing to populate this.$route in an unrelated test
wrapper.vm.getDefaultIsInsuranceSelectedValue = jest.fn();
//Act
await quote.beforeRouteEnter.call(
wrapper.vm,
{ query: { fmgPage: "quote" } },
undefined,
(c) => c(wrapper.vm)
);
//Assert
expect(wrapper.vm.pricedGlassParts !== null).toBe(true);
expect(wrapper.vm.supportingItems !== null).toBe(true);
expect(wrapper.vm.availableLineItems !== null).toBe(true);
// This should have its own test
//expect(vm.isInsuranceSelected !== null).toBe(true);
});
test("should default to insurance if query param 'isInsurance' is true", async () => {
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
lineItems: {
glassParts: ["item", "item2"],
},
payment: {
isInsurance: null,
},
},
};
wrapper.vm.$route = { query: { isInsurance: "true" } };
//Act
await quote.beforeRouteEnter.call(
wrapper.vm,
{ query: { fmgPage: "quote" } },
undefined,
(c) => c(wrapper.vm)
);
//Assert
expect(wrapper.vm.isInsuranceSelected).toBe(true);
});
test("should default to cash if query param 'isInsurance' is false", async () => {
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
lineItems: {
glassParts: ["item", "item2"],
},
payment: {
isInsurance: null,
},
},
};
wrapper.vm.$route = { query: { isInsurance: "false" } };
//Act
await quote.beforeRouteEnter.call(
wrapper.vm,
{ query: { fmgPage: "quote" } },
undefined,
(c) => c(wrapper.vm)
);
//Assert
expect(wrapper.vm.isInsuranceSelected).toBe(false);
});
test("should default to insurance if insurance selection is saved to store", async () => {
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
lineItems: {
glassParts: ["item", "item2"],
},
payment: {
isInsurance: true,
},
},
};
// Ensure that query param isn't overriding selection
wrapper.vm.$route = { query: null };
//Act
await quote.beforeRouteEnter.call(
wrapper.vm,
{ query: { fmgPage: "quote" } },
undefined,
(c) => c(wrapper.vm)
);
//Assert
expect(wrapper.vm.isInsuranceSelected).toBe(true);
});
test("should default to cash if cash selection is saved to store", async () => {
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
lineItems: {
glassParts: ["item", "item2"],
},
payment: {
isInsurance: false,
},
},
};
// Ensure that query param isn't overriding selection
wrapper.vm.$route = { query: null };
//Act
await quote.beforeRouteEnter.call(
wrapper.vm,
{ query: { fmgPage: "quote" } },
undefined,
(c) => c(wrapper.vm)
);
//Assert
expect(wrapper.vm.isInsuranceSelected).toBe(false);
});
test("should default to cash if total economy package price is under $500", async () => {
// Also needs no query parameter or previous selection in store to be present
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
lineItems: {
glassParts: ["item", "item2"],
},
payment: {
isInsurance: null, // Ensure that previous selection isn't overriding selection
},
},
};
mockTierOnePrice = 200;
// Ensure that query param isn't overriding selection
wrapper.vm.$route = { query: null };
//Act
await quote.beforeRouteEnter.call(
wrapper.vm,
{ query: { fmgPage: "quote" } },
undefined,
(c) => c(wrapper.vm)
);
//Assert
expect(wrapper.vm.isInsuranceSelected).toBe(false);
});
test("should default to insurance if total economy package price is over $500", async () => {
// Also needs no query parameter or previous selection in store to be present
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
lineItems: {
glassParts: ["item", "item2"],
},
payment: {
isInsurance: null, // Ensure that previous selection isn't overriding selection
},
},
};
mockTierOnePrice = 505;
// Ensure that query param isn't overriding selection
wrapper.vm.$route = { query: null };
//Act
await quote.beforeRouteEnter.call(
wrapper.vm,
{ query: { fmgPage: "quote" } },
undefined,
(c) => c(wrapper.vm)
);
//Assert
expect(wrapper.vm.isInsuranceSelected).toBe(true);
});
});
function setupMocks({ customMountOptions }) {
const mountOptions = getMountOptions({
...customMountOptions,
});
mountOptions.global.mocks["$store"] = store;
mountOptions["attachTo"] = document.body;
const wrapper = shallowMount(quote, mountOptions);
wrapper.vm.setCmsContent = jest.fn();
return { wrapper };
}

View file

@ -6,13 +6,8 @@
<vehicleBanner
cmsWidgetName="VehicleBannerWidget"
:displayGenericVehicleImage="false" />
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" />
<funnelSubHeader class="mb-5" cmsWidgetName="FunnelSubHeaderWidget" />
<div class="fade-on-route-transition sub-container make-tall">
<textBlock
cmsWidgetName="paymentServiceOptionsCopy"
justifyText="center"
typeStyle="small"
class="mt-2 mb-5" />
<cashOrInsuranceQuestion
ref="cashOrInsurance"
cmsWidgetName="CashOrInsuranceQuestionWidget"
@ -26,6 +21,7 @@
:availableLineItems="availableLineItems"
:isInsuranceSelected="isInsuranceSelected"
@vapsItemsSelected="vapsItemsSelectedAction"
v-on="{ 'buttonEvent.openModal': openModalAction }"
validationRules="option-required"
isRequired />
@ -34,10 +30,10 @@
justifyText="left"
typeStyle="caption"
class="mt-4" />
<modal cmsWidgetName="RainDefenseModal" />
<modal cmsWidgetName="FrontWiperModal" />
<modal cmsWidgetName="RearWiperModal" />
<modal cmsWidgetName="RecalModal" />
<contentGroupModal ref="RainDefenseModal" cmsWidgetName="RainDefenseModal" />
<contentGroupModal ref="FrontWiperModal" cmsWidgetName="FrontWiperModal" />
<contentGroupModal ref="RearWiperModal" cmsWidgetName="RearWiperModal" />
<contentGroupModal ref="RecalModal" cmsWidgetName="RecalModal" />
<funnel-footer
cmsWidgetName="FunnelFooterWidget"
:isForwardActionDisabled="!meta.valid"
@ -51,15 +47,15 @@
<script>
// Components
import funnelHeader from "@/common-components/funnel-header/funnel-header";
import funnelFooter from "@/common-components/funnel-footer/funnel-footer";
import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header";
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import funnelFooter from "@/fmg-components/funnel-footer/funnel-footer";
import vehicleBanner from "@/fmg-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import cashOrInsuranceQuestion from "./cash-or-insurance-question/cash-or-insurance-question";
import servicePackageQuestion from "./service-package-question/service-package-question";
import textBlock from "@/common-components/text-block/text-block";
import modal from "@/common-components/modal/modal";
import loadingModal from "@/common-components/loading-modal/loading-modal.vue";
import textBlock from "@/digital-components/text-block/text-block";
import contentGroupModal from "@/fmg-components/content-group-modal/content-group-modal";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import baseMixin from "@/mixins/base-mixin.js";
import vehicleQuestionsMixin from "../../mixins/vehicle-questions-mixin";
import { settleAllPromises } from "@/helpers/layout-helper";
@ -107,23 +103,28 @@ export default {
];
const resultMap = await settleAllPromises(promiseResultMap);
const glassParts = store.getters.order.lineItems.glassParts ?? [];
const clonedGlassParts = store.getters.order.lineItems.glassParts
? JSON.parse(JSON.stringify(store.getters.order.lineItems.glassParts))
: [];
const availableLineItems = [
resultMap.rainDefense,
...resultMap.supportingItems,
...resultMap.wipers,
...glassParts,
...clonedGlassParts,
];
const pricingResults = await baseMixin.methods.dispatchStoreAction(
storeActions.PRICE_ORDER_ITEMS,
availableLineItems,
storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA,
{
availableLineItems: availableLineItems,
},
false
);
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.pricedGlassParts = clonedGlassParts;
vm.supportingItems = resultMap.supportingItems;
vm.availableLineItems = pricingResults;
vm.isInsuranceSelected = vm.getDefaultIsInsuranceSelectedValue(vm.availableLineItems);
@ -135,9 +136,13 @@ export default {
selectedVaps: null,
availableLineItems: null,
supportingItems: null,
pricedGlassParts: null,
};
},
methods: {
openModalAction(modalName) {
this.$refs[modalName].openModal();
},
arePagePrerequisitesValid() {
return (
store.getters.order.serviceLocation.zipCode &&
@ -179,14 +184,25 @@ export default {
);
if (!this.isInsuranceSelected) {
this.dispatchStoreAction(
this.storeActions.SAVE_ACCOUNT_NUMBER,
applicationConfig.CASH_ACCOUNT_NUMBER,
this.storeActions.SAVE_PARENT_ACCOUNT_NUMBER,
applicationConfig.CASH_PARENT_ACCOUNT_NUMBER,
false
);
}
if (this.$store.getters.order.accountNumber != applicationConfig.CASH_ACCOUNT_NUMBER) {
if (
this.$store.getters.order.payment.parentAccountNumber !=
applicationConfig.CASH_PARENT_ACCOUNT_NUMBER
) {
this.supportingItems = this.filterOutFees(this.supportingItems);
}
if (this.pricedGlassParts.length > 0) {
this.dispatchStoreAction(
this.storeActions.SAVE_GLASS_PARTS,
this.pricedGlassParts,
false
);
}
this.dispatchStoreAction(
this.storeActions.SAVE_SUPPORTING_ITEMS,
this.supportingItems,
@ -194,7 +210,15 @@ export default {
);
this.dispatchStoreAction(this.storeActions.SAVE_VAPS, this.selectedVaps, false);
navigateToHeritageFunnel({ loadingModal: this.$refs.loadingModal });
const payment = this.$store.getters.payment;
if (payment.isInsurance) {
navigateToHeritageFunnel({ loadingModal: this.$refs.loadingModal });
} else {
this.$router.navigateWithSaving(
this.navigationScenarios.CLICKED_FORWARD_WITH_CASH,
this.$route
);
}
},
},
components: {
@ -206,7 +230,7 @@ export default {
textBlock,
cashOrInsuranceQuestion,
servicePackageQuestion,
modal,
contentGroupModal,
loadingModal,
},
};

View file

@ -135,6 +135,213 @@ describe("service-package-question.vue", () => {
expectedModifiedAnswers[packageNameKey].tierThree
);
});
it("should not select a default package if ANY glass part or supporting item has no prices", async () => {
// Note, only the first WSREPAIR item has a 0 for laborAmount, this is enough to not try and select a default package
const wrapper = setupMocks({
mountOptionsMockData: {
store: {
getters: {
lineItems: {
glassParts: [
{
partNumber: "FW02627GBYNOEE",
description: "solar, 3rd visor band",
color: "Green Tint, Blue Shade",
partType: "WINDSHIELD",
canSafeliteRecalibrate: false,
requiresRecalibration: false,
requiresCapabilityQuestions: false,
recalibrationType: null,
childParts: null,
laborAmount: 5.0,
},
],
supportingItems: [
{
partNumber: "SUPPLIES-REPAIR",
description: null,
partType: "REPAIR FEE",
laborAmount: 5.0,
},
{
partNumber: "WSREPAIR",
description: null,
partType: "REPAIR FEE",
laborAmount: 0,
},
{
partNumber: "WSREPAIR",
description: null,
partType: "REPAIR FEE",
laborAmount: 5.0,
},
{
partNumber: "WSREPAIR",
description: null,
partType: "REPAIR FEE",
laborAmount: 5.0,
},
],
},
order: {
damage: {
isRepair: false,
glassToReplace: [{ glassLocation: "Windshield" }],
},
},
},
},
},
});
// Act
wrapper.setProps({ availableLineItems: null });
await nextTick();
// Assert
expect(wrapper.vm.selectedPackageName).toBe(null);
});
it("should select a default package if ALL glass parts and supporting items have prices", async () => {
const wrapper = setupMocks({
mountOptionsMockData: {
store: {
getters: {
lineItems: {
glassParts: [
{
partNumber: "FW02627GBYNOEE",
description: "solar, 3rd visor band",
color: "Green Tint, Blue Shade",
partType: "WINDSHIELD",
canSafeliteRecalibrate: false,
requiresRecalibration: false,
requiresCapabilityQuestions: false,
recalibrationType: null,
childParts: null,
laborAmount: 5.0,
},
],
supportingItems: [
{
partNumber: "SUPPLIES-REPAIR",
description: null,
partType: "REPAIR FEE",
laborAmount: 5.0,
},
{
partNumber: "WSREPAIR",
description: null,
partType: "REPAIR FEE",
laborAmount: 5.0,
},
{
partNumber: "WSREPAIR",
description: null,
partType: "REPAIR FEE",
laborAmount: 5.0,
},
{
partNumber: "WSREPAIR",
description: null,
partType: "REPAIR FEE",
laborAmount: 5.0,
},
],
},
order: {
damage: {
isRepair: false,
glassToReplace: [{ glassLocation: "Windshield" }],
},
},
},
},
},
});
// Act
wrapper.setProps({ availableLineItems: null });
await nextTick();
// Assert
expect(wrapper.vm.selectedPackageName).toBe("TierOne");
});
it("should select TierThree default package if Rain Defense is on order", async () => {
// This tests the business intent that when returning to the page, the selected package is the lowest package that contains all VAPs on the order.
// In this case Rain Defense is only in Premium so Tier Three should be defaulted to
const wrapper = setupMocks({
mountOptionsMockData: {
store: {
getters: {
lineItems: {
glassParts: [
{
partNumber: "FW02627GBYNOEE",
description: "solar, 3rd visor band",
color: "Green Tint, Blue Shade",
partType: "WINDSHIELD",
canSafeliteRecalibrate: false,
requiresRecalibration: false,
requiresCapabilityQuestions: false,
recalibrationType: null,
childParts: null,
laborAmount: 5.0,
},
],
vaps: [
{
partNumber: "RAIN DEFENSE",
partType: "RAIN DEFENSE",
},
],
supportingItems: [
{
partNumber: "SUPPLIES-REPAIR",
description: null,
partType: "REPAIR FEE",
laborAmount: 5.0,
},
{
partNumber: "WSREPAIR",
description: null,
partType: "REPAIR FEE",
laborAmount: 5.0,
},
{
partNumber: "WSREPAIR",
description: null,
partType: "REPAIR FEE",
laborAmount: 5.0,
},
{
partNumber: "WSREPAIR",
description: null,
partType: "REPAIR FEE",
laborAmount: 5.0,
},
],
},
order: {
damage: {
isRepair: false,
glassToReplace: [{ glassLocation: "Windshield" }],
},
},
},
},
},
});
// Act
wrapper.setProps({ availableLineItems: null });
await nextTick();
// Assert
expect(wrapper.vm.selectedPackageName).toBe("TierThree");
});
});
describe("service-package-question.vue, matching business rules for package display", () => {
// mock scenarios in figma:

View file

@ -1,5 +1,6 @@
<template>
<buttonQuestion
ref="buttonQuestion"
:answers="servicePackageAnswers"
:groupName="groupName"
buttonTypeString="servicePackageRadio"
@ -10,7 +11,7 @@
</template>
<script>
import buttonQuestion from "@/common-components/button-question/button-question";
import buttonQuestion from "@/digital-components/button-question/button-question";
import baseMixin from "@/mixins/base-mixin.js";
import { processIfStatements } from "@/helpers/cms-content-helper";
import { damageLocationsSelected as glassLocations } from "@/constants/damage-locations-selected";
@ -42,7 +43,7 @@ export default {
},
watch: {
availableLineItems() {
if (this.$store.getters.lineItems.supportingItems) {
if (this.allGlassPartsAndSupportingItemsHavePrices(this.$store.getters.lineItems)) {
this.selectDefaultPackage();
}
},
@ -212,20 +213,46 @@ export default {
selectDefaultPackage() {
const vapsFromStore = this.$store.getters.lineItems.vaps;
let lowestTierForPackage = packageNames.TIER_ONE;
vapsFromStore.every((vapsItem) => {
let lowestTierForThisItem = this.getLowestTierForThisItem(vapsItem);
if (lowestTierForThisItem === packageNames.TIER_THREE) {
lowestTierForPackage = packageNames.TIER_THREE;
return false;
} else if (lowestTierForThisItem === packageNames.TIER_TWO) {
lowestTierForPackage = packageNames.TIER_TWO;
return true;
} else {
return true;
}
});
if (vapsFromStore?.length > 0) {
vapsFromStore.every((vapsItem) => {
let lowestTierForThisItem = this.getLowestTierForThisItem(vapsItem);
if (lowestTierForThisItem === packageNames.TIER_THREE) {
lowestTierForPackage = packageNames.TIER_THREE;
return false;
} else if (lowestTierForThisItem === packageNames.TIER_TWO) {
lowestTierForPackage = packageNames.TIER_TWO;
return true;
} else {
return true;
}
});
}
this.selectedPackageName = lowestTierForPackage;
},
allGlassPartsAndSupportingItemsHavePrices(lineItems) {
if (lineItems?.glassParts) {
for (let i = 0; i < lineItems.glassParts.length; i++) {
if (this.priceIsNullOrZero(lineItems.glassParts[i])) {
return false;
}
}
}
if (lineItems?.supportingItems) {
for (let i = 0; i < lineItems.supportingItems.length; i++) {
if (this.priceIsNullOrZero(lineItems.supportingItems[i])) {
return false;
}
}
}
return true;
},
priceIsNullOrZero(lineItem) {
return (
(lineItem.kitPrice == null || lineItem.kitPrice == 0) &&
(lineItem.laborAmount == null || lineItem.laborAmount == 0) &&
(lineItem.sellingPrice == null || lineItem.sellingPrice == 0)
);
},
getLowestTierForThisItem(vapsItem) {
let lowestTierForThisItem = null;
switch (vapsItem.partType) {

View file

@ -30,10 +30,15 @@
<span v-else>
<textLink
linkType="text"
:text="getLinkDisplayTextFromCopy(copy)"
:text="getRouterLinkDisplayTextFromCopy(copy)"
href="#!"
data-bs-toggle="modal"
:data-bs-target="'#' + getLinkTargetFromCopy(copy)"
@click-event="
$emit('buttonEvent', {
eventName: 'openModal',
args: getRouterLinkRouteFromCopy(copy),
})
"
:data-bs-target="'#' + getRouterLinkRouteFromCopy(copy)"
aria-label="Modal window" />
</span>
</template>
@ -51,12 +56,12 @@
</template>
<script>
import baseInputButton from "@/common-components/base-input-button/base-input-button";
import baseInputButton from "@/digital-components/base-input-button/base-input-button";
import textLink from "@/ux-components/text-link/text-link";
import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
import {
getLinkDisplayTextFromCopy,
getLinkTargetFromCopy,
getRouterLinkDisplayTextFromCopy,
getRouterLinkRouteFromCopy,
splitCopyOnCMSPlaceHolder,
doesCopyContainTextLink,
} from "@/helpers/cms-content-helper";
@ -74,8 +79,8 @@ export default {
},
},
methods: {
getLinkDisplayTextFromCopy,
getLinkTargetFromCopy,
getRouterLinkDisplayTextFromCopy,
getRouterLinkRouteFromCopy,
splitCopyOnCMSPlaceHolder,
doesCopyContainTextLink,
stripUlTagFromCopy(copy) {
@ -229,6 +234,11 @@ export default {
li {
margin-bottom: 0.5rem;
font-size: 0.875rem;
line-height: 1.714;
a {
line-height: 1.714;
padding: 0;
}
}
}

View file

@ -1,66 +0,0 @@
<template>
<div class="container-fluid shadow rounded-3 p-2 position-relative make-tall">
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
<h1>Reveal Confetti Page Placeholder</h1>
<funnelFooter
cmsWidgetName="FunnelFooterWidget"
ref="funnelFooter"
@back-clicked="backButtonAction" />
</div>
</template>
<script>
// Components
import funnelHeader from "@/common-components/funnel-header/funnel-header";
import funnelFooter from "@/common-components/funnel-footer/funnel-footer";
// Supporting Files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
import { storeMutations } from "@/constants/store-mutations";
import { storeActions } from "@/constants/store-actions";
import store from "@/store";
export default {
name: "quote",
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
// Settle promises and get results
const promiseResultMap = [
{
resultKey: "cmsContent",
promise: cmsContentPromise,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
});
},
methods: {
backButtonAction() {
// route to move backwards
this.$router.navigateWithoutSaving(this.navigationScenarios.CLICKED_BACK, this.$route);
},
arePagePrerequisitesValid() {
return true;
},
resetDependentState() {
// Set
store.commit(storeMutations.UPDATE_GLASS_PARTS, null);
// Invokes
store.dispatch(storeActions.RESET_PARTS_AND_DEPS);
},
},
data() {},
components: {
funnelHeader,
funnelFooter,
},
};
</script>

View file

@ -0,0 +1,136 @@
<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm" v-slot="{ meta }">
<div class="page-container-grouped-styles">
<loadingModal ref="loadingModal" />
<funnelHeader cmsWidgetName="FunnelHeaderWidget" />
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" class="mt-5" />
<div class="text-center mt-1 mb-3" v-if="ChangeShopLink.length">
<span v-for="copy in ChangeShopLink" :key="copy">
<span v-if="doesCopyContainRouterLink(copy)" class="text-body">
<router-link
:to="{
query: { fmgPage: `${getRouterLinkRouteFromCopy(copy)}` },
name: 'root',
}"
>{{ getRouterLinkDisplayTextFromCopy(copy) }}</router-link
>
</span>
<span v-else class="m-0 text-body" v-html="copy"></span>
</span>
</div>
<date-picker
selectableDates="custom"
v-model="selectedDate"
:customSelectableDatesCallback="getAvailableDates" />
<funnel-footer
cmsWidgetName="FunnelFooterWidget"
ref="funnelFooter"
:isForwardActionDisabled="!meta.valid"
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction" />
</div>
</Form>
</template>
<script>
// Components
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import funnelFooter from "@/fmg-components/funnel-footer/funnel-footer";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import { Form, defineRule } from "vee-validate";
import datePicker from "@/digital-components/date-picker/date-picker";
// Supporting files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import {
doesCopyContainRouterLink,
splitCopyOnCMSPlaceHolder,
getRouterLinkRouteFromCopy,
getRouterLinkDisplayTextFromCopy,
} from "@/helpers/cms-content-helper";
import { errorMessages } from "@/constants/error-messages";
import { required } from "@/helpers/validation-rules";
defineRule("date-required", required(errorMessages.DATE_REQUIRED));
export default {
name: "schedule",
data() {
return {
selectedDate: null,
mockSelectableDatesData: [
{ year: 2023, month: 4, date: 13 },
{ year: 2023, month: 4, date: 14 },
{ year: 2023, month: 4, date: 26 },
{ year: 2023, month: 5, date: 4 },
{ year: 2023, month: 5, date: 5 },
{ year: 2023, month: 5, date: 14 },
{ year: 2023, month: 5, date: 21 },
{ year: 2023, month: 5, date: 23 },
{ year: 2023, month: 5, date: 25 },
{ year: 2023, month: 6, date: 11 },
],
};
},
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
// Settle promises and get results
const promiseResultMap = [
{
resultKey: "cmsContent",
promise: cmsContentPromise,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
});
},
computed: {
ChangeShopLinkText() {
return this.getCmsContent("ChangeShopLink", "Text");
},
ChangeShopLink() {
// Splits content when brackets are found in text so that text can be looped through and router-link can be injected when needed
return this.splitCopyOnCMSPlaceHolder(this.ChangeShopLinkText);
},
},
methods: {
doesCopyContainRouterLink,
splitCopyOnCMSPlaceHolder,
getRouterLinkRouteFromCopy,
getRouterLinkDisplayTextFromCopy,
arePagePrerequisitesValid() {
return true;
// NEED TODO - WHAT ARE PAGE REQ'S FOR THIS PAGE?
},
getAvailableDates(startDate, endDate) {
return this.mockSelectableDatesData;
},
backButtonAction() {
this.$router.navigateWithoutSaving(this.navigationScenarios.CLICKED_BACK, this.$route);
},
forwardButtonAction() {
navigateToHeritageFunnel({ loadingModal: this.$refs.loadingModal });
},
},
components: {
funnelHeader,
funnelFooter,
funnelSubHeader,
Form,
loadingModal,
datePicker,
},
};
</script>
<style lang="scss"></style>

View file

@ -0,0 +1,177 @@
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import appointmentTypeQuestion from "./appointment-type-question";
const mockCmsContent = {
QuestionText: "Choose a service option:",
Answers: [
{
AnswerImageUrl: "",
Name: "Mobile",
SubText: "",
SubWidgetName: "",
Text: "Mobile",
},
{
AnswerImageUrl: "",
Name: "Inshop",
SubText: "",
SubWidgetName: "",
Text: "In-shop",
},
{
AnswerImageUrl: "",
Name: "Dropoff",
SubText: "",
SubWidgetName: "",
Text: "Drop-off",
},
],
};
const cmsWidgetName = "AppointmentTypeQuestionWidget";
const mockMixin = {
methods: {
getCmsContent: jest.fn((widgetName, cmsFieldName) => {
if (widgetName === cmsWidgetName) {
return mockCmsContent[cmsFieldName];
}
return null;
}),
},
};
describe("appointment-type-question.vue", () => {
it("Should display all options if both in-shop and mobile are available", async () => {
// Arrange/Act
const { wrapper } = setupMocks({
mixins: [mockMixin],
props: {
cmsWidgetName: cmsWidgetName,
isServiceableInshop: true,
isServiceableMobile: true,
},
mountOptions: {
attachTo: document.body,
},
});
// Assert
expect(wrapper.vm.answersToDisplay).toEqual([
{
AnswerImageUrl: "",
Name: "Mobile",
SubText: "",
SubWidgetName: "",
Text: "Mobile",
},
{
AnswerImageUrl: "",
Name: "Inshop",
SubText: "",
SubWidgetName: "",
Text: "In-shop",
},
{
AnswerImageUrl: "",
Name: "Dropoff",
SubText: "",
SubWidgetName: "",
Text: "Drop-off",
},
]);
});
it("Should display only the In-Shop and Drop-Off answers when only in-shop service is available", async () => {
// Arrange/Act
const { wrapper } = setupMocks({
mixins: [mockMixin],
props: {
cmsWidgetName: cmsWidgetName,
isServiceableInshop: true,
isServiceableMobile: false,
},
mountOptions: {
attachTo: document.body,
},
});
// Assert
expect(wrapper.vm.answersToDisplay).toEqual([
{
AnswerImageUrl: "",
Name: "Inshop",
SubText: "",
SubWidgetName: "",
Text: "In-shop",
},
{
AnswerImageUrl: "",
Name: "Dropoff",
SubText: "",
SubWidgetName: "",
Text: "Drop-off",
},
]);
});
it("Should display only the Mobile answer when only mobile service is available", async () => {
// Arrange/Act
const { wrapper } = setupMocks({
mixins: [mockMixin],
props: {
cmsWidgetName: cmsWidgetName,
isServiceableInshop: false,
isServiceableMobile: true,
},
mountOptions: {
attachTo: document.body,
},
});
// Assert
expect(wrapper.vm.answersToDisplay).toEqual([
{
AnswerImageUrl: "",
Name: "Mobile",
SubText: "",
SubWidgetName: "",
Text: "Mobile",
},
]);
});
it("Should display no answers if neither in-shop nor mobile service are available", async () => {
// Arrange/Act
const { wrapper } = setupMocks({
mixins: [mockMixin],
props: {
cmsWidgetName: cmsWidgetName,
isServiceableInshop: false,
isServiceableMobile: false,
},
mountOptions: {
attachTo: document.body,
},
});
// Assert
expect(wrapper.vm.answersToDisplay).toEqual([]);
});
});
function setupMocks({ mountOptions, mixins, props, isShallowMount = true }) {
const resultingMountOptions = getMountOptions({
...mountOptions,
mixins,
});
if (props) resultingMountOptions.propsData = props;
const wrapper = isShallowMount
? shallowMount(appointmentTypeQuestion, resultingMountOptions)
: mount(appointmentTypeQuestion, resultingMountOptions);
return { wrapper };
}

View file

@ -0,0 +1,90 @@
<template>
<transition name="fade" mode="out-in">
<div class="appointment-type-question" aria-live="polite">
<buttonQuestion
customButtonQuestionId="appointmentTypeQuestion"
:questionText="questionText"
:answers="answersToDisplay"
:groupName="groupName"
buttonTypeString="listCard"
v-model="selectedValues"
:suppressError="suppressError"
:validationRules="validationRules"
isRequired />
</div>
</transition>
</template>
<script>
import buttonQuestion from "@/digital-components/button-question/button-question";
export default {
name: "appointment-type-question",
props: {
modelValue: String,
groupName: String,
isAvailable: Boolean,
suppressError: Boolean,
validationRules: String,
cmsWidgetName: String,
isServiceableMobile: Boolean,
isServiceableInshop: Boolean,
},
computed: {
questionText() {
return this.getCmsContent(this.cmsWidgetName, "QuestionText");
},
answersFromCms() {
return this.getCmsContent(this.cmsWidgetName, "Answers");
},
answersToDisplay() {
let filteredAnswers;
if (this.isServiceableMobile && this.isServiceableInshop) {
filteredAnswers = this.answersFromCms;
} else if (this.isServiceableMobile) {
filteredAnswers = this.answersFromCms.filter((answer) => answer.Name == "Mobile");
} else if (this.isServiceableInshop) {
filteredAnswers = this.answersFromCms.filter(
(answer) => answer.Name == "Inshop" || answer.Name == "Dropoff"
);
} else {
filteredAnswers = [];
}
return filteredAnswers;
},
selectedValues: {
get: function () {
return this.modelValue;
},
set: function (newValue) {
this.$emit("update:modelValue", newValue);
},
},
isMobileOnly() {
return this.isServiceableMobile && !this.isServiceableInshop;
},
},
watch: {
isMobileOnly: {
handler(newValue) {
if (newValue) {
this.selectedValues = "Mobile";
} else {
this.selectedValues = null;
}
},
},
},
components: {
buttonQuestion,
},
};
</script>
<style lang="scss">
.list-card img {
height: auto;
width: 3.417rem;
}
</style>

View file

@ -0,0 +1,27 @@
// For nested objects, spread operator only creates new references to the top level fields,
// the remaining nested fields actually reference the original object which can introduce problems.
// The purpose of this method is to deep clone the data in an object recursively, this is useful
// for cloning modelValues to internal models when regular two-way binding is not an option.
// See: mobile-location-modal-questions.vue
// Creates a deep clone of an object. Clones primitives, arrays and objects, excluding class instances.
// https://www.30secondsofcode.org/js/s/deep-clone
export function deepClone(object) {
if (object === null) {
return null;
}
let clone = Object.assign({}, object);
Object.keys(clone).forEach(
(key) =>
(clone[key] = typeof object[key] === "object" ? deepClone(object[key]) : object[key])
);
if (Array.isArray(object)) {
clone.length = object.length;
return Array.from(clone);
}
return clone;
}

View file

@ -0,0 +1,48 @@
import { deepClone } from "./object-cloning-helper";
describe("object-cloning-helper.js", () => {
it("Should return null if no object is passed in", async () => {
// Arrange
const expected = null;
// Act
const result = deepClone(null);
// Assert
expect(result).toEqual(expected);
});
it("Should return a deep copy of the object", async () => {
// Arrange
const object = {
addressQuestions: {
streetAddress: "555 Some St",
apartmentNumberOrBusinessName: "Apt 1",
city: "Funkytown",
state: "OH",
zipCode: "55555",
},
isVehicleProtected: true,
serviceZipCode: "55555",
};
const expected = {
addressQuestions: {
streetAddress: "555 Some St",
apartmentNumberOrBusinessName: "Apt 1",
city: "Funkytown",
state: "OH",
zipCode: "55555",
},
isVehicleProtected: true,
serviceZipCode: "55555",
};
// Act
const result = deepClone(object);
// Assert
expect(result).toStrictEqual(expected);
});
});

View file

@ -0,0 +1,44 @@
import { storeActions } from "@/constants/store-actions";
import baseMixin from "@/mixins/base-mixin.js";
export async function getPricedMobileFeePart(serviceZipCode) {
if (!serviceZipCode) {
return Promise.resolve(null);
}
const zipCodeData = await baseMixin.methods.getZipCodeData(serviceZipCode);
// Get the Mobile Fee Part
const mobileFeePart = await baseMixin.methods.dispatchStoreAction(
storeActions.GET_MOBILE_FEE_PART,
null,
false
);
// Get the Mobile Fee Part Price
const pricingResults = await baseMixin.methods.dispatchStoreAction(
storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA,
{
availableLineItems: [mobileFeePart.data],
serviceZipCode: serviceZipCode,
serviceZipCodeCtu: zipCodeData.zipCodeCtu,
},
false
);
return Promise.resolve(pricingResults[0]);
}
export async function getServiceabilityDetails(serviceZipCode, lineItems) {
// Get the Mobile Fee Part
const serviceabilityDetails = await baseMixin.methods.dispatchStoreAction(
storeActions.GET_SERVICEABILITY_DETAILS,
{
serviceZipCode: serviceZipCode,
lineItems: lineItems,
},
false
);
return Promise.resolve(serviceabilityDetails);
}

View file

@ -0,0 +1,95 @@
import { getPricedMobileFeePart } from "./service-location-helper";
import { storeActions } from "@/constants/store-actions";
const mockStoreActionGetMobileFeePart = storeActions.GET_MOBILE_FEE_PART;
const mockStoreActionPriceOrderItemsAndSaveServerData =
storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA;
jest.mock("@/mixins/base-mixin.js", () => ({
methods: {
getZipCodeData: jest.fn().mockImplementation(() => {
return Promise.resolve({
containsMilitaryBase: false,
isServiceable: true,
isValid: true,
state: "OH",
zipCodeCtu: "01820",
});
}),
dispatchStoreAction: jest.fn().mockImplementation((actionName) => {
if (actionName === mockStoreActionGetMobileFeePart) {
return Promise.resolve({
data: {
partNumber: "MOBILE FEE",
description: "MOBILE FEE",
partType: "FEE",
},
});
}
if (actionName === mockStoreActionPriceOrderItemsAndSaveServerData) {
return Promise.resolve([
{
partNumber: "MOBILE FEE",
description: "MOBILE FEE",
partType: "FEE",
laborAmount: 49.99,
sellingPrice: 0,
kitPrice: 0,
},
]);
}
}),
},
}));
describe("service-location-helper.js", () => {
it("Should return null if no service zip code is passed in", async () => {
// Arrange
const serviceZipCode = null;
const serviceType = "Replace";
const parentAccountNumber = 167132;
const billToAccountNumber = 1234;
const expected = null;
// Act
const result = await getPricedMobileFeePart(
serviceZipCode,
serviceType,
parentAccountNumber,
billToAccountNumber
);
// Assert
expect(result).toEqual(expected);
});
it("Should return the priced mobile fee part", async () => {
// Arrange
const serviceZipCode = "43235";
const serviceType = "Replace";
const parentAccountNumber = 167132;
const billToAccountNumber = 1234;
const expected = {
partNumber: "MOBILE FEE",
description: "MOBILE FEE",
partType: "FEE",
laborAmount: 49.99,
sellingPrice: 0,
kitPrice: 0,
};
// Act
const result = await getPricedMobileFeePart(
serviceZipCode,
serviceType,
parentAccountNumber,
billToAccountNumber
);
// Assert
expect(result).toEqual(expected);
});
});

View file

@ -0,0 +1,360 @@
import mobileLocationModalQuestions from "./mobile-location-modal-questions";
import { mount, shallowMount } from "@vue/test-utils";
import { storeActions } from "@/constants/store-actions";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import modal from "@/digital-components/modal/modal";
import crypto from "crypto";
global.crypto = crypto;
const linkWidgetName = "linkWidgetName";
const modalWidgetName = "modalWidgetName";
const mobileFeeDisclaimerWidgetName = "MobileFeeDisclaimerWidget";
const mockLinkCmsContent = {
BodyText: "Sample link body text here.",
};
const mockModalCmsContent = {
FooterText: "Sample modal footer text here.",
};
const mockMobileFeeDisclaimerContent = {
Text: "Sample mobile fee disclaimer text {custom:mobileFee}",
};
const mockMixin = {
methods: {
getCmsContent: jest.fn((widgetName, cmsFieldName) => {
if (widgetName === linkWidgetName) {
return mockLinkCmsContent[cmsFieldName];
}
if (widgetName === modalWidgetName) {
return mockModalCmsContent[cmsFieldName];
}
if (widgetName === mobileFeeDisclaimerWidgetName) {
return mockMobileFeeDisclaimerContent[cmsFieldName];
}
return null;
}),
getZipCodeData: jest.fn((zip) => {
if (zip === "43235" || zip === "55555") {
return Promise.resolve({
containsMilitaryBase: false,
isServiceable: true,
isValid: true,
state: "OH",
zipCodeCtu: "01820",
});
}
return Promise.resolve({
containsMilitaryBase: false,
isServiceable: false,
isValid: false,
state: null,
zipCodeCtu: null,
});
}),
dispatchStoreAction: jest.fn((actionName) => {
if (actionName === storeActions.GET_MOBILE_FEE_PART) {
return Promise.resolve({
data: {
partNumber: "MOBILE FEE",
description: "MOBILE FEE",
partType: "FEE",
},
});
}
if (actionName === storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA) {
return Promise.resolve([
{
partNumber: "MOBILE FEE",
description: "MOBILE FEE",
partType: "FEE",
laborAmount: 49.99,
sellingPrice: 0,
kitPrice: 0,
},
]);
}
}),
getTotalLineItemPrice: jest.fn((lineItem) => {
return 49.99;
}),
},
};
const mockGetPricedMobileFeePart = (mockServiceZipCode) => {
let mobileFeePart = {};
if (mockServiceZipCode === "43235") {
mobileFeePart = {
partNumber: "MOBILE FEE",
description: "MOBILE FEE",
partType: "FEE",
laborAmount: 49.99,
sellingPrice: 0,
kitPrice: 0,
};
}
return Promise.resolve(mobileFeePart);
};
const mockGetServiceabilityDetails = (mockServiceZipCode) => {
const serviceabilityDetails = {
isGlassServiceableInshop: true,
isRecalibrationServiceableInshop: true,
isGlassServiceableMobile: true,
isRecalibrationServiceableMobile: true,
};
return Promise.resolve(serviceabilityDetails);
};
jest.mock(
"@/layouts/service-location/helpers/service-location-helper/service-location-helper",
() => ({
getPricedMobileFeePart: jest.fn((mockServiceZipCode) => {
return mockGetPricedMobileFeePart(mockServiceZipCode);
}),
getServiceabilityDetails: jest.fn((mockServiceZipCode) => {
return mockGetServiceabilityDetails(mockServiceZipCode);
}),
})
);
describe("mobile-location-modal-questions.vue", () => {
it("Should copy the modelValue to the internalModel when the component is mounted", async () => {
// Arrange
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "555 Some St",
apartmentNumberOrBusinessName: "Apt 1",
city: "Funkytown",
state: "OH",
zipCode: "55555",
},
isVehicleProtected: true,
serviceZipCode: "55555",
};
const { wrapper } = setupMocks({
mixins: [mockMixin],
props: {
modelValue: mobileLocationQuestions,
},
mountOptions: {
attachTo: document.body,
},
});
// Assert
expect(wrapper.vm.internalModel.addressQuestions.streetAddress).toEqual("555 Some St");
expect(wrapper.vm.internalModel.addressQuestions.apartmentNumberOrBusinessName).toEqual(
"Apt 1"
);
expect(wrapper.vm.internalModel.addressQuestions.city).toEqual("Funkytown");
expect(wrapper.vm.internalModel.addressQuestions.state).toEqual("OH");
expect(wrapper.vm.internalModel.addressQuestions.zipCode).toEqual("55555");
expect(wrapper.vm.internalModel.isVehicleProtected).toEqual(true);
expect(wrapper.vm.internalModel.serviceZipCode).toEqual("55555");
});
it("Should open the modal when the 'Enter your service address' link is clicked", async () => {
// Arrange
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "555 Some St",
apartmentNumberOrBusinessName: "Apt 1",
city: "Funkytown",
state: "OH",
zipCode: "55555",
},
isVehicleProtected: true,
serviceZipCode: "55555",
};
const { wrapper } = setupMocks({
mixins: [mockMixin],
props: {
modelValue: mobileLocationQuestions,
},
mountOptions: {
attachTo: document.body,
},
});
wrapper.vm.$refs.MobileLocationModalWidget.openModal = jest.fn();
const mobileLocationLink = wrapper.findComponent({ ref: "mobileLocationLink" });
// // Act
await mobileLocationLink.trigger("click-event");
// // Assert
expect(wrapper.vm.$refs.MobileLocationModalWidget.openModal).toHaveBeenCalled();
});
it("Should emit update:modelValue on setMobileLocation for a valid address and vehicle protection answer", async () => {
// Arrange
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
isVehicleProtected: true,
serviceZipCode: "",
};
const newMobileLocationQuestions = {
addressQuestions: {
streetAddress: "555 Some St",
apartmentNumberOrBusinessName: "Apt 1",
city: "Funkytown",
state: "OH",
zipCode: "55555",
},
isVehicleProtected: true,
serviceZipCode: "55555",
};
const wrapper = shallowMount(mobileLocationModalQuestions, {
mixins: [mockMixin],
props: {
modelValue: mobileLocationQuestions,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
wrapper.vm.$refs.MobileLocationModalWidget.closeModal = jest.fn();
// Act
wrapper.vm.internalModel = newMobileLocationQuestions;
await wrapper.vm.setMobileLocation();
let expectedEmit = [[newMobileLocationQuestions]];
// Assert
expect(wrapper.emitted("update:modelValue")).toEqual(expectedEmit);
});
it("Should not emit update:modelValue on setMobileLocation for an invalid address zip code", async () => {
// Arrange
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
isVehicleProtected: true,
serviceZipCode: "",
};
const newMobileLocationQuestions = {
addressQuestions: {
streetAddress: "555 Some St",
apartmentNumberOrBusinessName: "Apt 1",
city: "Funkytown",
state: "OH",
zipCode: "61000",
},
isVehicleProtected: true,
serviceZipCode: "61000",
};
const wrapper = shallowMount(mobileLocationModalQuestions, {
mixins: [mockMixin],
props: {
modelValue: mobileLocationQuestions,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
wrapper.vm.$refs.MobileLocationModalWidget.closeModal = jest.fn();
wrapper.vm.resetModalButtonStyle = jest.fn();
// Act
wrapper.vm.internalModel = newMobileLocationQuestions;
await wrapper.vm.setMobileLocation();
// Assert
expect(wrapper.emitted("update:modelValue")).not.toBeTruthy();
});
it("Should display invalid zip alert for invalid address zip inputs", async () => {
// Arrange
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
isVehicleProtected: true,
serviceZipCode: "",
};
const newMobileLocationQuestions = {
addressQuestions: {
streetAddress: "555 Some St",
apartmentNumberOrBusinessName: "Apt 1",
city: "Funkytown",
state: "OH",
zipCode: "11111",
},
isVehicleProtected: true,
serviceZipCode: "11111",
};
const wrapper = shallowMount(mobileLocationModalQuestions, {
mixins: [mockMixin],
props: {
modelValue: mobileLocationQuestions,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
wrapper.vm.internalModel = newMobileLocationQuestions;
wrapper.vm.resetModalButtonStyle = jest.fn();
// Act
await wrapper.vm.setMobileLocation();
// Assert
expect(wrapper.vm.displayInvalidZipAlert).toBe(true);
});
});
function setupMocks({ mountOptions, mixins, props, isShallowMount = true }) {
const resultingMountOptions = getMountOptions({
...mountOptions,
mixins,
});
if (props) resultingMountOptions.propsData = props;
const wrapper = isShallowMount
? shallowMount(mobileLocationModalQuestions, resultingMountOptions)
: mount(mobileLocationModalQuestions, resultingMountOptions);
return { wrapper };
}

View file

@ -0,0 +1,317 @@
<template>
<transition name="fade" mode="out-in">
<div class="mobile-location-questions">
<div class="text-center" :id="componentId">
<label
for="mobileLocationLinkPromptId"
:aria-label="mobileLocationLinkPromptText"
class="form-label fw-bold w-100 ps-4 pe-4 pt-4"
v-html="mobileLocationLinkPromptText"></label>
<div class="update-mobile-location-text-link">
<textLink
ref="mobileLocationLink"
id="mobileLocationLinkPromptId"
linkType="text"
:text="mobileLocationLinkText"
href="#!"
@click-event="openModal"
aria-label="Modal window" />
</div>
<textBlock
:customText="mobileFeeText"
cmsWidgetName="MobileFeeDisclaimerWidget"
typeStyle="caption" />
<div v-show="errorMessage" class="row my-1 form-test-error">
<span class="d-inline-flex small mt-0 center-error-message" role="alert">
{{ errorMessage }}
</span>
</div>
</div>
<modal
:ref="modalName"
:headerText="modalHeaderText"
:footerButtonText="modalFooterText"
:onModalOpenedCallback="onModalOpened"
:onModalClosedCallback="onModalClosed"
@footer-button-event="setMobileLocation">
<addressQuestions
ref="addressQuestions"
v-model="internalModel.addressQuestions"
captureApartmentNumberOrBusinessName="true" />
<vehicleProtectedQuestion
ref="vehicleProtectedQuestion"
v-model="internalModel.isVehicleProtected"
cmsWidgetName="VehicleProtectedQuestionWidget" />
<textBlock cmsWidgetName="WorkspaceRequirementsWidget" typeStyle="caption" />
<alert
ref="alertInvalidZip"
v-if="displayInvalidZipAlert"
class="my-4"
cmsWidgetName="AlertInvalidZipWidget"
alertClass="alert-danger"
v-bind:isDismissible="false" />
</modal>
</div>
</transition>
</template>
<script>
// Components
import textLink from "@/ux-components/text-link/text-link";
import textBlock from "@/digital-components/text-block/text-block";
import modal from "@/digital-components/modal/modal";
import alert from "@/ux-components/alert/alert";
import addressQuestions from "@/layouts/address-lookup/customer-questions/address-questions/address-questions";
import vehicleProtectedQuestion from "@/layouts/service-location/mobile-location-modal-questions/vehicle-protected-question/vehicle-protected-question";
// Helpers
import { deepClone } from "@/layouts/service-location/helpers/object-cloning-helper/object-cloning-helper";
import {
getPricedMobileFeePart,
getServiceabilityDetails,
} from "@/layouts/service-location/helpers/service-location-helper/service-location-helper";
// Validation
import { useField } from "vee-validate";
export default {
name: "mobile-location-modal-questions",
emits: ["update:modelValue", "updated-mobile-fee-part", "updated-contains-military-base"],
data() {
return {
internalModel: deepClone(this.modelValue),
displayInvalidZipAlert: false,
};
},
setup(props) {
const componentId = !props.customComponentId
? `component-${crypto.randomUUID()}`
: props.customComponentId;
// Integrate this component as a single field with an object for it's value into the page level validation
const modelValue = deepClone(props).modelValue;
const initialValue = modelValue;
const fieldOptions = {
value: modelValue,
initialValue: initialValue,
};
const { errorMessage, handleChange, meta, validate, errors } = useField(
componentId,
props.validationRules,
fieldOptions
);
return {
componentId,
errorMessage,
handleChange,
validate,
meta,
errors,
};
},
props: {
modelValue: {
type: Object,
default: () => ({
addressQuestions: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
isVehicleProtected: null,
}),
},
mobileFeePart: {
type: Object,
default: () => ({}),
},
linkWidgetName: String,
modalWidgetName: String,
alertNonServiceableZipWidgetName: String,
alertInvalidZipWidgetName: String,
customComponentId: String,
validationRules: String,
},
computed: {
mobileLocationLinkPromptText() {
return this.getCmsContent(this.linkWidgetName, "HeaderText");
},
mobileLocationLinkText() {
if (
this.addressModel.streetAddress &&
this.addressModel.streetAddress !== "" &&
this.addressModel.city &&
this.addressModel.city !== "" &&
this.addressModel.state &&
this.addressModel.state !== "" &&
this.addressModel.zipCode &&
this.addressModel.zipCode !== "" &&
this.internalModel.isVehicleProtected !== null
) {
return `${this.addressModel.streetAddress}\n${this.addressModel.city}, ${this.addressModel.state} ${this.addressModel.zipCode}`;
}
return this.getCmsContent(this.linkWidgetName, "BodyText");
},
modalName() {
return "MobileLocationModalWidget";
},
modalHeaderText() {
return this.getCmsContent(this.modalWidgetName, "HeaderText");
},
mobileFeeText() {
const cmsContentText = this.getCmsContent("MobileFeeDisclaimerWidget", "Text");
return cmsContentText.replaceAll("{custom:mobileFee}", this.mobileFee);
},
mobileFee() {
if (!this.mobileFeePart) {
return 0;
}
return (
this.mobileFeePart.laborAmount +
this.mobileFeePart.sellingPrice +
this.mobileFeePart.kitPrice
);
},
modalFooterText() {
return this.getCmsContent(this.modalWidgetName, "FooterText");
},
addressModel: {
get: function () {
return this.modelValue.addressQuestions;
},
},
},
methods: {
openModal() {
this.$refs[this.modalName].openModal();
},
closeModal() {
this.$refs[this.modalName].closeModal();
},
onModalOpened() {
this.internalModel = deepClone(this.modelValue);
},
onModalClosed() {
this.internalModel = deepClone(this.modelValue);
this.resetValidation();
},
resetComponent(updatedServiceZipCodeInfo) {
// Reset the validation form, setting the initial values
// for the state and zipCode to those that were entered
// on the service-zip-modal-question component
this.$refs[this.modalName].resetForm({
values: {
autocomplete: updatedServiceZipCodeInfo.streetAddress,
city: updatedServiceZipCodeInfo.city,
state: updatedServiceZipCodeInfo.state,
zipCode: updatedServiceZipCodeInfo.zipCode,
isVehicleProtected: updatedServiceZipCodeInfo.isVehicleProtected,
},
});
},
resetModalButtonStyle() {
this.$refs[this.modalName].resetButtonStyle();
},
resetValidation() {
this.$refs.addressQuestions.resetAlerts();
this.$refs[this.modalName].resetForm({
values: {
autocomplete: this.internalModel.addressQuestions.streetAddress,
city: this.internalModel.addressQuestions.city,
state: this.internalModel.addressQuestions.state,
zipCode: this.internalModel.addressQuestions.zipCode,
isVehicleProtected: this.internalModel.isVehicleProtected,
},
});
},
async setMobileLocation() {
// Validate the Zip Code
const zipCodeData = await this.getZipCodeData(
this.internalModel.addressQuestions.zipCode
);
if (!zipCodeData.isValid) {
this.displayInvalidZipAlert = true;
this.resetModalButtonStyle();
} else {
// retrieve mobile fee part
const serviceZipCode = this.internalModel.addressQuestions.zipCode;
const mobileFeePart = await getPricedMobileFeePart(serviceZipCode);
// retrieve serviceability details
const serviceabilityDetails = await getServiceabilityDetails(serviceZipCode);
// update content related to service zip code
this.$emit("updated-mobile-fee-part", mobileFeePart);
this.$emit("updated-serviceability", serviceabilityDetails.data);
this.$emit("updated-contains-military-base", zipCodeData.containsMilitaryBase);
// Update the page level model
this.$emit("update:modelValue", this.internalModel);
this.closeModal();
}
},
},
watch: {
modelValue: {
handler(newValue) {
this.internalModel = deepClone(newValue);
this.handleChange(newValue);
this.resetComponent({
streetAddress: newValue.addressQuestions.streetAddress,
city: newValue.addressQuestions.city,
state: newValue.addressQuestions.state,
zipCode: newValue.addressQuestions.zipCode,
isVehicleProtected: newValue.isVehicleProtected,
});
},
deep: true,
},
},
components: {
textLink,
modal,
addressQuestions,
vehicleProtectedQuestion,
textBlock,
alert,
},
};
</script>
<style lang="scss" scoped>
.mobile-location-questions {
.center-error-message {
justify-content: center !important;
}
}
.update-zip-text-link::before {
content: "";
display: inline-block;
width: 13px;
height: 16px;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 13 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.49635 1.00142e-07C5.64734 -0.000153295 4.80722 0.175918 4.0274 0.517444C3.24757 0.858969 2.54443 1.35877 1.96099 1.98626C0.765713 3.27588 0.0999756 4.98141 0.0999756 6.75394C0.0999756 8.52646 0.765713 10.232 1.96099 11.5216L5.98324 15.777C6.04954 15.8475 6.12918 15.9036 6.21736 15.9419C6.30555 15.9802 6.40045 16 6.49635 16C6.59225 16 6.68716 15.9802 6.77534 15.9419C6.86353 15.9036 6.94317 15.8475 7.00946 15.777L11.0317 11.52C12.2391 10.2383 12.909 8.52914 12.8999 6.75394C12.9094 4.97818 12.2394 3.26832 11.0317 1.98626C10.4481 1.35899 9.74493 0.859347 8.96514 0.517839C8.18535 0.176331 7.34532 0.000130509 6.49635 1.00142e-07V1.00142e-07ZM6.49635 9.13131C6.02507 9.13131 5.56437 8.98913 5.17251 8.72275C4.78065 8.45637 4.47524 8.07776 4.29488 7.63479C4.11453 7.19181 4.06734 6.70438 4.15928 6.23412C4.25123 5.76387 4.47817 5.33191 4.81142 4.99287C5.14467 4.65384 5.56925 4.42295 6.03148 4.32941C6.49371 4.23587 6.97282 4.28388 7.40823 4.46736C7.84364 4.65085 8.21579 4.96157 8.47762 5.36023C8.73945 5.7589 8.87921 6.2276 8.87921 6.70707C8.87921 7.34974 8.62837 7.96611 8.18185 8.4207C7.73532 8.87528 7.12964 9.13088 6.49794 9.13131H6.49635Z' fill='%231574A1'/%3E%3C/svg%3E%0A");
background-size: contain;
vertical-align: middle;
margin-right: 0.5em;
}
.address-questions {
margin-top: 0em;
}
#mobileLocationLinkPromptId {
white-space: pre-line;
}
</style>

View file

@ -0,0 +1,71 @@
import { shallowMount } from "@vue/test-utils";
import vehicleProtectedQuestion from "./vehicle-protected-question";
jest.mock("@/digital-components/textbox-question/textbox-question", () => ({
getCmsContent: jest.fn(),
}));
const mockMixin = {
methods: {
getCmsContent: jest.fn((widgetName, cmsFieldName) => {
if (cmsFieldName === "Answers") {
return [
{
AnswerImageUrl: "",
Name: "YesAnswer",
SubText: "",
SubWidgetName: "",
Text: "Yes",
},
{
AnswerImageUrl: "",
Name: "NoAnswer",
SubText: "",
SubWidgetName: "",
Text: "No",
},
];
}
return "QuestionText";
}),
},
};
describe("service-zip-question.vue", () => {
it("Should get the modelValue", async () => {
// Arrange
const answer = "YesAnswer";
const wrapper = shallowMount(vehicleProtectedQuestion, {
mixins: [mockMixin],
props: {
modelValue: answer,
},
attachTo: document.body,
});
// Act
const modelValueAnswer = wrapper.vm.selectedValue;
// Assert
expect(modelValueAnswer).toEqual(answer);
});
it("Should emit to set value", async () => {
// Arrange
const yesAnswer = "YesAnswer";
const noAnswer = "NoAnswer";
const wrapper = shallowMount(vehicleProtectedQuestion, {
mixins: [mockMixin],
props: {
modelValue: yesAnswer,
},
attachTo: document.body,
});
// Act
wrapper.vm.selectedValue = noAnswer;
// Assert
expect(wrapper.emitted("update:modelValue")).toEqual([[noAnswer]]);
});
});

View file

@ -0,0 +1,61 @@
<template>
<buttonQuestion
class="radioQuestion"
:questionText="questionText"
:answers="answers"
groupName="isVehicleProtected"
textPosition="text-center"
v-model="selectedValue"
validationRules="option-required"
isRequired />
</template>
<script>
import buttonQuestion from "@/digital-components/button-question/button-question";
// Supporting files
import { defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
defineRule("option-required", required(errorMessages.OPTION_REQUIRED));
export default {
name: "vehicle-protected-question",
props: {
modelValue: {
isVehicleProtected: Boolean,
},
cmsWidgetName: String,
},
components: {
buttonQuestion,
},
computed: {
questionText() {
return this.getCmsContent(this.cmsWidgetName, "QuestionText");
},
selectedValue: {
get: function () {
return this.modelValue;
},
set: function (newValue) {
this.$emit("update:modelValue", newValue);
},
},
answers() {
return this.getCmsContent(this.cmsWidgetName, "Answers");
},
},
};
</script>
<style lang="scss">
.modal-dialog {
.question-text {
& > span {
text-align: left;
}
}
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,367 @@
<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm" v-slot="{ meta }">
<div class="page-container-grouped-styles">
<loadingModal ref="loadingModal" />
<funnelHeader cmsWidgetName="FunnelHeaderWidget" />
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" />
<serviceZipModalQuestion
v-model="serviceZipCodeQuestion"
ref="serviceZipCodeQuestion"
:mobileFeePart="mobileFeePart"
@updated-mobile-fee-part="setMobileFeePart"
@updated-serviceability="setServiceabilityDetails"
@updated-contains-military-base="setContainsMilitaryBase"
linkWidgetName="ServiceZipLinkWidget"
modalWidgetName="ServiceZipModalWidget" />
<alert
ref="alertMilitaryBaseZip"
class="my-4"
cmsWidgetName="AlertMilitaryBaseZipWidget"
v-if="displayMilitaryZipAlert"
alertClass="alert-warning" />
<alert
ref="alertMobileOnly"
class="my-4"
cmsWidgetName="AlertMobileOnlyWidget"
v-if="displayServiceableMobileOnly"
alertClass="alert-warning" />
<alert
ref="alertRecalNoMobile"
class="my-4"
cmsWidgetName="AlertRecalNoMobileWidget"
v-if="displayRecalibrationWarning"
@text-link-clicked="openModalAction"
alertClass="alert-warning" />
<alert
ref="alertInshopOnly"
class="my-4"
cmsWidgetName="AlertInshopOnlyWidget"
v-if="displayServiceableInshopOnly"
alertClass="alert-warning" />
<alert
ref="alertNoShops"
class="my-4"
cmsWidgetName="AlertNoShopsWidget"
v-if="displayNoShopsAlert"
alertClass="alert-warning" />
<appointmentTypeQuestion
v-model="selectedAppointmentType"
:isServiceableMobile="isServiceableMobile"
:isServiceableInshop="isServiceableInshop"
ref="appointmentTypeQuestion"
groupName="appointmentTypeQuestion"
cmsWidgetName="AppointmentTypeQuestionWidget"
validationRules="option-required" />
<mobileLocationModalQuestions
customComponentId="mobileLocationQuestions"
v-if="selectedAppointmentType === 'Mobile'"
v-model="mobileLocationQuestions"
:mobileFeePart="mobileFeePart"
@updated-mobile-fee-part="setMobileFeePart"
@updated-serviceability="setServiceabilityDetails"
@updated-contains-military-base="setContainsMilitaryBase"
validationRules="mobile-location-required"
ref="mobileLocationQuestions"
linkWidgetName="MobileLocationLinkWidget"
modalWidgetName="MobileLocationModalWidget" />
<contentGroupModal ref="RecalModal" cmsWidgetName="RecalModal" />
<funnel-footer
cmsWidgetName="FunnelFooterWidget"
ref="funnelFooter"
:isForwardActionDisabled="!meta.valid || displayNoShopsAlert"
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction" />
</div>
</Form>
</template>
<script>
// Components
import alert from "@/ux-components/alert/alert";
import serviceZipModalQuestion from "@/layouts/service-location/service-zip-modal-question/service-zip-modal-question";
import mobileLocationModalQuestions from "@/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions";
import appointmentTypeQuestion from "@/layouts/service-location/appointment-type-question/appointment-type-question";
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import funnelFooter from "@/fmg-components/funnel-footer/funnel-footer";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import { Form } from "vee-validate";
import contentGroupModal from "@/fmg-components/content-group-modal/content-group-modal";
// Supporting files
import baseMixin from "@/mixins/base-mixin.js";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import {
getPricedMobileFeePart,
getServiceabilityDetails,
} from "@/layouts/service-location/helpers/service-location-helper/service-location-helper";
import store from "@/store";
// Validation
import { defineRule } from "vee-validate";
import { errorMessages } from "@/constants/error-messages";
// DEFINE VALIDATION RULES
defineRule("mobile-location-required", (value) => {
if (
value.addressQuestions.streetAddress == "" ||
value.addressQuestions.city == "" ||
value.addressQuestions.state == "" ||
value.addressQuestions.zipCode == "" ||
value.isVehicleProtected == null
) {
return errorMessages.MOBILE_LOCATION_REQUIRED;
}
return true;
});
export default {
name: "service-location",
data() {
return {
streetAddress: this.getServiceAddressFromStore(),
apartmentNumberOrBusinessName: "",
city: this.getServiceCityFromStore(),
state: this.getServiceStateFromStore(),
zipCode: this.getServiceZipCodeFromStore(),
isVehicleProtected: null,
isGlassServiceableInshop: null,
isRecalibrationServiceableInshop: null,
isGlassServiceableMobile: null,
isRecalibrationServiceableMobile: null,
mobileFeePart: null,
zipContainsMilitaryBase: false,
selectedAppointmentType: "",
providerNumber: null,
};
},
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
const serviceZipCode = store.getters.order.serviceLocation.zipCode;
const getZipCodeData = baseMixin.methods.getZipCodeData(serviceZipCode);
const serviceabilityDetailsPromise = getServiceabilityDetails(serviceZipCode);
const mobileFeePartPromise = getPricedMobileFeePart(serviceZipCode);
// Settle promises and get results
const promiseResultMap = [
{
resultKey: "cmsContent",
promise: cmsContentPromise,
},
{
resultKey: "mobileFeePart",
promise: mobileFeePartPromise,
},
{
resultKey: "serviceabilityDetails",
promise: serviceabilityDetailsPromise,
},
{
resultKey: "zipCodeData",
promise: getZipCodeData,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.setData(
resultMap.zipCodeData,
resultMap.serviceabilityDetails,
resultMap.mobileFeePart
);
});
},
computed: {
serviceZipCodeQuestion: {
get: function () {
return {
state: this.state,
zipCode: this.zipCode,
};
},
set: function (newValue) {
if (newValue.zipCode !== this.zipCode) {
this.resetMobileLocation();
this.selectedAppointmentType = null;
this.providerNumber = null;
}
this.state = newValue.state;
this.zipCode = newValue.zipCode;
this.$nextTick();
},
},
mobileLocationQuestions: {
get: function () {
return {
addressQuestions: {
streetAddress: this.streetAddress,
apartmentNumberOrBusinessName: this.apartmentNumberOrBusinessName,
city: this.city,
state: this.state,
zipCode: this.zipCode,
},
isVehicleProtected: this.isVehicleProtected,
};
},
set: function (newValue) {
this.streetAddress = newValue.addressQuestions.streetAddress;
this.apartmentNumberOrBusinessName =
newValue.addressQuestions.apartmentNumberOrBusinessName;
this.city = newValue.addressQuestions.city;
this.state = newValue.addressQuestions.state;
this.zipCode = newValue.addressQuestions.zipCode;
this.isVehicleProtected = newValue.isVehicleProtected;
if (newValue.zipCode !== this.zipCode) {
if (!this.selectedAppointmentType == "Mobile") {
this.selectedAppointmentType = null;
}
this.providerNumber = null;
}
},
},
isServiceableMobile() {
if (this.isRecalibrationServiceableMobile !== null) {
return this.isGlassServiceableMobile && this.isRecalibrationServiceableMobile;
} else {
return this.isGlassServiceableMobile;
}
},
isServiceableInshop() {
if (this.isRecalibrationServiceableInshop !== null) {
return this.isGlassServiceableInshop && this.isRecalibrationServiceableInshop;
} else {
return this.isGlassServiceableInshop;
}
},
// Specifically check for isRecalibrationServiceableMobile === false, not null or true.
requiresInshopRecalibration() {
return (
this.isServiceableInshop &&
this.isGlassServiceableMobile &&
this.isRecalibrationServiceableMobile === false
);
},
displayRecalibrationWarning() {
return this.requiresInshopRecalibration;
},
displayServiceableInshopOnly() {
return (
!this.displayRecalibrationWarning &&
this.isServiceableInshop &&
!this.isServiceableMobile
);
},
displayMilitaryZipAlert() {
return this.zipContainsMilitaryBase && this.isServiceableMobile;
},
displayServiceableMobileOnly() {
return this.isServiceableMobile && !this.isServiceableInshop;
},
displayNoShopsAlert() {
return !this.isServiceableInshop && !this.isServiceableMobile;
},
},
methods: {
arePagePrerequisitesValid() {
return (
store.getters.lineItems.supportingItems !== null &&
store.getters.order.serviceLocation.zipCode !== null &&
store.getters.payment.isInsurance !== null
);
},
setData(zipCodeData, serviceabilityDetails, mobileFeePart) {
if (zipCodeData) {
this.zipContainsMilitaryBase = zipCodeData.containsMilitaryBase;
}
if (serviceabilityDetails) {
this.setServiceabilityDetails(serviceabilityDetails);
}
if (mobileFeePart) {
this.mobileFeePart = mobileFeePart;
}
},
setContainsMilitaryBase(val) {
if (this.zipContainsMilitaryBase !== val) {
this.zipContainsMilitaryBase = val;
}
},
getServiceAddressFromStore() {
return store.getters.order.serviceLocation.address;
},
getServiceCityFromStore() {
return store.getters.order.serviceLocation.city;
},
getServiceStateFromStore() {
return store.getters.order.serviceLocation.state;
},
getServiceZipCodeFromStore() {
return store.getters.order.serviceLocation.zipCode;
},
setMobileFeePart(mobileFeePart) {
this.mobileFeePart = mobileFeePart;
},
resetMobileLocation() {
this.streetAddress = "";
this.apartmentNumberOrBusinessName = "";
this.city = "";
this.isVehicleProtected = null;
},
setServiceabilityDetails(serviceabilityDetails) {
this.isGlassServiceableInshop = serviceabilityDetails.isGlassServiceableInshop;
this.isRecalibrationServiceableInshop =
serviceabilityDetails.isRecalibrationServiceableInshop;
this.isGlassServiceableMobile = serviceabilityDetails.isGlassServiceableMobile;
this.isRecalibrationServiceableMobile =
serviceabilityDetails.isRecalibrationServiceableMobile;
},
backButtonAction() {
this.$router.navigateWithoutSaving(this.navigationScenarios.CLICKED_BACK, this.$route);
},
forwardButtonAction() {
this.$router.navigateWithoutSaving(
this.navigationScenarios.SELECTED_LOCATION,
this.$route
);
},
openModalAction(modalName) {
this.$refs[modalName].openModal();
},
},
components: {
alert,
serviceZipModalQuestion,
appointmentTypeQuestion,
mobileLocationModalQuestions,
funnelHeader,
funnelFooter,
funnelSubHeader,
Form,
loadingModal,
contentGroupModal,
},
};
</script>
<style lang="scss">
.question-text {
& > span {
text-align: center;
}
}
</style>

View file

@ -0,0 +1,295 @@
import { mount, shallowMount } from "@vue/test-utils";
import serviceZipModalQuestion from "./service-zip-modal-question";
import crypto from "crypto";
global.crypto = crypto;
jest.mock("@/digital-components/textbox-question/textbox-question", () => ({
getCmsContent: jest.fn((widgetName, cmsFieldName) => {
return widgetName[cmsFieldName];
}),
}));
jest.mock("@/digital-components/modal/modal", () => ({
methods: {
closeModal: jest.fn(),
resetButtonStyle: jest.fn(),
},
}));
const mockGetPricedMobileFeePart = (mockServiceZipCode) => {
let mobileFeePart = {};
if (mockServiceZipCode === "43235") {
mobileFeePart = {
partNumber: "MOBILE FEE",
description: "MOBILE FEE",
partType: "FEE",
laborAmount: 49.99,
sellingPrice: 0,
kitPrice: 0,
};
}
return Promise.resolve(mobileFeePart);
};
const mockGetServiceabilityDetails = (mockServiceZipCode) => {
const serviceabilityDetails = {
isGlassServiceableInshop: true,
isRecalibrationServiceableInshop: true,
isGlassServiceableMobile: true,
isRecalibrationServiceableMobile: true,
};
return Promise.resolve(serviceabilityDetails);
};
jest.mock(
"@/layouts/service-location/helpers/service-location-helper/service-location-helper",
() => ({
getPricedMobileFeePart: jest.fn((mockServiceZipCode) => {
return mockGetPricedMobileFeePart(mockServiceZipCode);
}),
getServiceabilityDetails: jest.fn((mockServiceZipCode) => {
return mockGetServiceabilityDetails(mockServiceZipCode);
}),
})
);
const linkWidgetName = "linkWidgetName";
const modalWidgetName = "modalWidgetName";
const mockLinkCmsContent = {
BodyText: "Sample link body text here.",
};
const mockModalCmsContent = {
FooterText: "Sample modal footer text here.",
};
const mockMixin = {
methods: {
getCmsContent: jest.fn((widgetName, cmsFieldName) => {
if (widgetName === linkWidgetName) {
return mockLinkCmsContent[cmsFieldName];
}
if (widgetName === modalWidgetName) {
return mockModalCmsContent[cmsFieldName];
}
return null;
}),
getZipCodeData: jest.fn((zip) => {
if (zip === "43235") {
return {
containsMilitaryBase: false,
isValid: true,
state: "OH",
zipCodeCtu: "01820",
};
}
if (zip === "61606") {
return {
containsMilitaryBase: false,
isValid: true,
state: "IL",
zipCodeCtu: "01526",
};
}
return {
containsMilitaryBase: false,
isValid: false,
state: null,
zipCodeCtu: null,
};
}),
},
};
describe("service-zip-modal-question.vue", () => {
it("Initial state on load with no existing location info", async () => {
let serviceZipCodeQuestion = {
state: "",
zipCode: "",
};
// Arrange
const wrapper = mount(serviceZipModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: serviceZipCodeQuestion,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Assert
expect(wrapper.html()).toEqual(expect.stringContaining(mockLinkCmsContent["BodyText"]));
});
it("Initial state on load with existing location info", async () => {
// Arrange
let serviceZipCodeQuestion = {
state: "OH",
zipCode: "43235",
};
const wrapper = mount(serviceZipModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: serviceZipCodeQuestion,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Assert
expect(wrapper.html()).not.toEqual(expect.stringContaining(mockLinkCmsContent["BodyText"]));
});
it("Should emit update:modelValue on setZipCode for a valid, serviceable zip", async () => {
// Arrange
let serviceZipCodeQuestion = {
state: "IL",
zipCode: "61606",
};
const zip = "43235";
const wrapper = mount(serviceZipModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: serviceZipCodeQuestion,
mobileFeePart: {},
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Act
wrapper.vm.internalModel.zipCode = zip;
await wrapper.vm.setZipCode();
let expectedEmit = [[{ state: "OH", zipCode: "43235" }]];
// Assert
expect(wrapper.emitted("update:modelValue")).toEqual(expectedEmit);
});
it("Should not emit update:modelValue on setZipCode for an invalid zip", async () => {
// Arrange
let serviceZipCodeQuestion = {
state: "IL",
zipCode: "61606",
};
const zip = "";
const wrapper = mount(serviceZipModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: serviceZipCodeQuestion,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Act
wrapper.vm.internalModel.zipCode = zip;
await wrapper.vm.setZipCode();
// Assert
expect(wrapper.emitted("update:modelValue")).not.toBeTruthy();
});
it("Should display invalid zip alerts for invalid ip inputs", async () => {
// Arrange
let serviceZipCodeQuestion = {
state: "IL",
zipCode: "61606",
};
const zip = "11111";
const wrapper = mount(serviceZipModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: serviceZipCodeQuestion,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Act
wrapper.vm.internalModel.zipCode = zip;
await wrapper.vm.setZipCode();
// Assert
expect(wrapper.vm.displayInvalidZipAlert).toBe(true);
});
it("Should reset all alerts on onModalClosed", async () => {
// Arrange
let serviceZipCodeQuestion = {
state: "IL",
zipCode: "61606",
};
const zip = "";
const wrapper = mount(serviceZipModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: serviceZipCodeQuestion,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Act
wrapper.vm.internalModel.zipCode = zip;
await wrapper.vm.setZipCode();
wrapper.vm.onModalClosed();
// Assert
expect(wrapper.vm.displayInvalidZipAlert).toBe(false);
});
it("Should prepopulate the zip on onModalOpened", async () => {
// Arrange
let serviceZipCodeQuestion = {
state: "IL",
zipCode: "61606",
};
const zip = "123";
const wrapper = mount(serviceZipModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: serviceZipCodeQuestion,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Act
wrapper.vm.internalModel.zipCode = zip;
wrapper.vm.onModalOpened();
// Assert
expect(wrapper.vm.internalModel.zipCode).toEqual("61606");
});
});

View file

@ -0,0 +1,201 @@
<template>
<div class="text-center">
<div class="update-zip-text-link">
<textLink
id="serviceZipLinkPromptId"
linkType="text"
:text="serviceZipLinkText"
href="#!"
@click-event="openModal"
aria-label="Modal window" />
</div>
</div>
<modal
:ref="modalName"
:headerText="modalHeaderText"
:onModalOpenedCallback="onModalOpened"
:onModalClosedCallback="onModalClosed"
:footerButtonText="modalFooterText"
@footer-button-event="setZipCode">
<serviceZipQuestion
ref="serviceZipQuestion"
customInputId="serviceZipCode"
v-model="internalModel.zipCode"
v-on="{ 'textboxQuestionEvent.inputIdAssigned': onInputIdAssigned }"
:cmsWidgetName="textboxQuestionWidgetName" />
<alert
ref="alertInvalidZip"
v-if="displayInvalidZipAlert"
class="my-4"
:cmsWidgetName="alertInvalidZipWidgetName"
alertClass="alert-danger"
v-bind:isDismissible="false" />
</modal>
</template>
<script>
// Components
import textLink from "@/ux-components/text-link/text-link";
import serviceZipQuestion from "@/layouts/service-location/service-zip-modal-question/service-zip-question/service-zip-question";
import modal from "@/digital-components/modal/modal";
import alert from "@/ux-components/alert/alert";
import {
getPricedMobileFeePart,
getServiceabilityDetails,
} from "@/layouts/service-location/helpers/service-location-helper/service-location-helper";
export default {
name: "service-zip-modal-question",
emits: ["update:modelValue", "updated-mobile-fee-part", "updated-contains-military-base"],
data() {
return {
internalModel: this.copyModel(this.modelValue),
serviceZipCodeTextInputId: "",
displayInvalidZipAlert: false,
};
},
props: {
modelValue: {
type: Object,
default: () => ({
state: "",
zipCode: "",
}),
},
mobileFeePart: {
type: Object,
default: () => ({}),
},
linkWidgetName: String,
modalWidgetName: String,
},
setup() {
const textboxQuestionWidgetName = "ServiceZipQuestionWidget";
const alertInvalidZipWidgetName = "AlertInvalidZipWidget";
return {
textboxQuestionWidgetName,
alertInvalidZipWidgetName,
};
},
computed: {
serviceZipLinkText() {
if (this.modelValue.zipCode && this.modelValue.zipCode.length > 0) {
return this.modelValue.state + ", " + this.modelValue.zipCode;
}
return this.getCmsContent(this.linkWidgetName, "BodyText");
},
modalHeaderText() {
return this.getCmsContent(this.textboxQuestionWidgetName, "QuestionText");
},
modalFooterText() {
return this.getCmsContent(this.modalWidgetName, "FooterText");
},
modalName() {
return this.modalWidgetName;
},
},
methods: {
resetAlerts() {
this.displayInvalidZipAlert = false;
},
resetsOnZipInput() {
this.resetAlerts();
},
focusOnZipInput() {
const input = document.getElementById(this.serviceZipCodeTextInputId);
input?.focus();
},
copyModel(modelToCopy) {
return {
state: modelToCopy.state,
zipCode: modelToCopy.zipCode,
};
},
openModal() {
this.$refs[this.modalName].openModal();
},
closeModal() {
this.$refs[this.modalName].closeModal();
},
resetModalButtonStyle() {
this.$refs[this.modalName].resetButtonStyle();
},
onInputIdAssigned(inputId) {
this.serviceZipCodeTextInputId = inputId;
},
onModalOpened() {
this.internalModel.zipCode = this.modelValue.zipCode;
this.focusOnZipInput();
},
onModalClosed() {
this.internalModel.zipCode = this.modelValue.zipCode;
this.resetsOnZipInput();
},
async setZipCode() {
if (this.internalModel.zipCode !== this.modelValue.zipCode) {
this.resetAlerts();
const zipCodeData = await this.getZipCodeData(this.internalModel.zipCode);
if (!zipCodeData.isValid) {
this.displayInvalidZipAlert = true;
this.focusOnZipInput();
this.resetModalButtonStyle();
} else {
this.internalModel.state = zipCodeData.state;
// retrieve mobile fee part
const serviceZipCode = this.internalModel.zipCode;
const mobileFeePart = await getPricedMobileFeePart(serviceZipCode);
// retrieve serviceability details
const serviceabilityDetails = await getServiceabilityDetails(serviceZipCode);
// update content related to service zip code
this.$emit("updated-mobile-fee-part", mobileFeePart);
this.$emit("updated-serviceability", serviceabilityDetails.data);
this.$emit("updated-contains-military-base", zipCodeData.containsMilitaryBase);
// Update the page level model
this.$emit("update:modelValue", this.internalModel);
this.closeModal();
}
} else {
this.closeModal();
}
},
},
watch: {
modelValue(newValue) {
this.internalModel = this.copyModel(newValue);
},
"internalModel.zipCode": {
handler() {
this.resetsOnZipInput();
},
},
},
components: {
textLink,
serviceZipQuestion,
modal,
alert,
},
};
</script>
<style lang="scss" scoped>
.update-zip-text-link::before {
content: "";
display: inline-block;
width: 13px;
height: 16px;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 13 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.49635 1.00142e-07C5.64734 -0.000153295 4.80722 0.175918 4.0274 0.517444C3.24757 0.858969 2.54443 1.35877 1.96099 1.98626C0.765713 3.27588 0.0999756 4.98141 0.0999756 6.75394C0.0999756 8.52646 0.765713 10.232 1.96099 11.5216L5.98324 15.777C6.04954 15.8475 6.12918 15.9036 6.21736 15.9419C6.30555 15.9802 6.40045 16 6.49635 16C6.59225 16 6.68716 15.9802 6.77534 15.9419C6.86353 15.9036 6.94317 15.8475 7.00946 15.777L11.0317 11.52C12.2391 10.2383 12.909 8.52914 12.8999 6.75394C12.9094 4.97818 12.2394 3.26832 11.0317 1.98626C10.4481 1.35899 9.74493 0.859347 8.96514 0.517839C8.18535 0.176331 7.34532 0.000130509 6.49635 1.00142e-07V1.00142e-07ZM6.49635 9.13131C6.02507 9.13131 5.56437 8.98913 5.17251 8.72275C4.78065 8.45637 4.47524 8.07776 4.29488 7.63479C4.11453 7.19181 4.06734 6.70438 4.15928 6.23412C4.25123 5.76387 4.47817 5.33191 4.81142 4.99287C5.14467 4.65384 5.56925 4.42295 6.03148 4.32941C6.49371 4.23587 6.97282 4.28388 7.40823 4.46736C7.84364 4.65085 8.21579 4.96157 8.47762 5.36023C8.73945 5.7589 8.87921 6.2276 8.87921 6.70707C8.87921 7.34974 8.62837 7.96611 8.18185 8.4207C7.73532 8.87528 7.12964 9.13088 6.49794 9.13131H6.49635Z' fill='%231574A1'/%3E%3C/svg%3E%0A");
background-size: contain;
vertical-align: middle;
margin-right: 0.5em;
}
</style>

View file

@ -0,0 +1,40 @@
import { shallowMount } from "@vue/test-utils";
import serviceZipQuestion from "./service-zip-question";
describe("service-zip-question.vue", () => {
it("Should get the modelValue", async () => {
// Arrange
const text = "test";
const wrapper = shallowMount(serviceZipQuestion, {
props: {
modelValue: text,
},
attachTo: document.body,
});
// Act
const modelValueText = wrapper.vm.value;
wrapper.vm.value = "test also";
// Assert
expect(modelValueText).toEqual("test");
});
it("Should emit to set value", async () => {
// Arrange
const text = "test";
const wrapper = shallowMount(serviceZipQuestion, {
props: {
modelValue: text,
},
attachTo: document.body,
});
// Act
const modelValueText = wrapper.vm.value;
wrapper.vm.value = "test also";
// Assert
expect(wrapper.emitted("update:modelValue")).toEqual([["test also"]]);
});
});

View file

@ -0,0 +1,49 @@
<template>
<textboxQuestion
ref="zipInputTextQuestion"
:cmsWidgetName="cmsWidgetName"
v-model="value"
inputId="serviceZipCode"
questionAlignment="center"
cornerStyle="rounded"
mask="#####"
:displayQuestionText="false"
isRequired
validationRules="zip-required|zip-format" />
</template>
<script>
import textboxQuestion from "@/digital-components/textbox-question/textbox-question";
//Supporting Files
import { defineRule } from "vee-validate";
import { required, regex } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
// Define Validation Rules
defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED));
defineRule("zip-format", regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.SERVICE_ZIP_FORMAT));
export default {
name: "service-zip-question",
props: {
modelValue: {
serviceZipCode: String,
},
cmsWidgetName: String,
},
computed: {
value: {
get: function () {
return this.modelValue;
},
set: function (newValue) {
this.$emit("update:modelValue", newValue);
},
},
},
components: {
textboxQuestion,
},
};
</script>

View file

@ -13,7 +13,7 @@
</template>
<script>
import buttonQuestion from "@/common-components/button-question/button-question";
import buttonQuestion from "@/digital-components/button-question/button-question";
import store from "@/store";
import { defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";

View file

@ -21,7 +21,7 @@
</template>
<script>
import buttonQuestion from "@/common-components/button-question/button-question";
import buttonQuestion from "@/digital-components/button-question/button-question";
import store from "@/store";
export default {

View file

@ -40,8 +40,8 @@
</template>
<script>
import buttonQuestion from "@/common-components/button-question/button-question";
import replaceOptionsQuestion from "@/layouts/vehicle-damage/replace-options-question/replace-options-question";
import buttonQuestion from "@/digital-components/button-question/button-question";
import replaceOptionsQuestion from "../replace-options-question/replace-options-question";
import store from "@/store";
import { defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";

View file

@ -1,3 +1,4 @@
fmg
<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm" v-slot="{ meta }">
<div class="page-container-grouped-styles">
@ -58,10 +59,10 @@
<script>
// Components
import funnelHeader from "@/common-components/funnel-header/funnel-header";
import funnelFooter from "@/common-components/funnel-footer/funnel-footer";
import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header";
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import funnelFooter from "@/fmg-components/funnel-footer/funnel-footer";
import vehicleBanner from "@/fmg-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-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";
@ -324,6 +325,17 @@ export default {
false
);
if (this.isWindshieldRepair) {
const supportingItems = await this.dispatchStoreAction(
storeActions.GET_SUPPORTING_ITEMS
);
this.dispatchStoreAction(
this.storeActions.SAVE_SUPPORTING_ITEMS,
supportingItems.data,
false
);
}
return this.navigateForward();
},

View file

@ -15,7 +15,7 @@
</template>
<script>
import buttonQuestion from "@/common-components/button-question/button-question";
import buttonQuestion from "@/digital-components/button-question/button-question";
export default {
name: "windshieldOptions",

View file

@ -15,7 +15,7 @@
</template>
<script>
import buttonQuestion from "@/common-components/button-question/button-question";
import buttonQuestion from "@/digital-components/button-question/button-question";
export default {
name: "windshieldDamageTypeQuestion",

View file

@ -12,7 +12,7 @@
</template>
<script>
import buttonQuestion from "@/common-components/button-question/button-question";
import buttonQuestion from "@/digital-components/button-question/button-question";
// Supporting files
import store from "@/store";
import { storeActions } from "@/constants/store-actions.js";

View file

@ -7,7 +7,8 @@
<funnelSubHeader
cmsWidgetName="FunnelSubHeaderWidget"
:hasBackButton="true"
@click-event="backButtonAction" />
@click-event="backButtonAction"
headerColor="light-header" />
<div class="fade-on-route-transition">
<makeQuestion
v-model="selectedMake"
@ -22,9 +23,9 @@
<script>
// Components
import makeQuestion from "@/layouts/vehicle-make/make-question/make-question";
import funnelHeader from "@/common-components/funnel-header/funnel-header";
import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header";
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import vehicleBanner from "@/fmg-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
// Supporting files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";

View file

@ -12,7 +12,7 @@
</template>
<script>
import buttonQuestion from "@/common-components/button-question/button-question";
import buttonQuestion from "@/digital-components/button-question/button-question";
// Supporting files
import store from "@/store";
import { storeActions } from "@/constants/store-actions.js";

View file

@ -7,7 +7,8 @@
<funnelSubHeader
cmsWidgetName="FunnelSubHeaderWidget"
:hasBackButton="true"
@click-event="backButtonAction" />
@click-event="backButtonAction"
headerColor="light-header" />
<div class="fade-on-route-transition">
<modelQuestion
v-model="selectedModel"
@ -22,9 +23,9 @@
<script>
// Components
import modelQuestion from "@/layouts/vehicle-model/model-question/model-question";
import funnelHeader from "@/common-components/funnel-header/funnel-header";
import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header";
import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner";
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import vehicleBanner from "@/fmg-components/vehicle-banner/vehicle-banner";
// Supporting files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { storeMutations } from "@/constants/store-mutations";

View file

@ -25,6 +25,7 @@
textPosition="text-start"
:loaderEnabled="false"
isRequired
isSmallQuestionText
:groupName="`${glassLocation}-${glassName}-${selectedTint}`"
:validationRules="partValidationRules" />
</div>
@ -37,7 +38,7 @@
<script>
// Components
import buttonQuestion from "@/common-components/button-question/button-question";
import buttonQuestion from "@/digital-components/button-question/button-question";
// Supporting files
import { getTintImage } from "@/constants/tint-mapper";

View file

@ -1,6 +1,6 @@
<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm">
<div class="page-container-grouped-styles vehicle-parts">
<div class="page-container-grouped-styles vehicle-parts small-question-text">
<loadingModal ref="loadingModal" />
<funnelHeader ref="funnelHeader" cmsWidgetName="FunnelHeaderWidget" />
<vehicleBanner
@ -45,12 +45,12 @@
<script>
// Components
import glassPartQuestion from "@/layouts/vehicle-parts/glass-part-question/glass-part-question";
import funnelHeader from "@/common-components/funnel-header/funnel-header";
import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header";
import funnelFooter from "@/common-components/funnel-footer/funnel-footer";
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import vehicleBanner from "@/fmg-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import funnelFooter from "@/fmg-components/funnel-footer/funnel-footer";
import alert from "@/ux-components/alert/alert";
import loadingModal from "@/common-components/loading-modal/loading-modal.vue";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
// Supporting Files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";

Some files were not shown because too many files have changed in this diff Show more