Initial Merge from Develop
This commit is contained in:
commit
edb1604c55
125 changed files with 7765 additions and 1478 deletions
|
|
@ -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
581
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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> = 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>
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
16
src/constants/scheduling.js
Normal file
16
src/constants/scheduling.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
const monthsOfYear = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
export { monthsOfYear };
|
||||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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: {
|
||||
|
|
@ -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", () => {
|
||||
|
|
@ -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;
|
||||
}
|
||||
437
src/digital-components/date-picker/date-picker.spec.js
Normal file
437
src/digital-components/date-picker/date-picker.spec.js
Normal 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 };
|
||||
}
|
||||
640
src/digital-components/date-picker/date-picker.vue
Normal file
640
src/digital-components/date-picker/date-picker.vue
Normal 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> = 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>
|
||||
|
|
@ -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],
|
||||
});
|
||||
|
|
@ -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 = "⁠";
|
||||
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) {
|
||||
255
src/digital-components/modal/modal.spec.js
Normal file
255
src/digital-components/modal/modal.spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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"
|
||||
*/
|
||||
|
|
@ -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, {
|
||||
|
|
@ -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");
|
||||
},
|
||||
},
|
||||
|
|
@ -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, {
|
||||
|
|
@ -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 = "⁠";
|
||||
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;
|
||||
|
|
@ -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",
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 },
|
||||
|
|
@ -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",
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
|
@ -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",
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
19
src/layouts/demo-date-picker/demo-date-picker.spec.js
Normal file
19
src/layouts/demo-date-picker/demo-date-picker.spec.js
Normal 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 };
|
||||
}
|
||||
83
src/layouts/demo-date-picker/demo-date-picker.vue
Normal file
83
src/layouts/demo-date-picker/demo-date-picker.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
427
src/layouts/quote/quote.spec.js
Normal file
427
src/layouts/quote/quote.spec.js
Normal 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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
136
src/layouts/schedule/schedule.vue
Normal file
136
src/layouts/schedule/schedule.vue
Normal 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>
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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]]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
1372
src/layouts/service-location/service-location.spec.js
Normal file
1372
src/layouts/service-location/service-location.spec.js
Normal file
File diff suppressed because it is too large
Load diff
367
src/layouts/service-location/service-location.vue
Normal file
367
src/layouts/service-location/service-location.vue
Normal 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>
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"]]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue