Merge branch 'release/2024.01.11' into rlsmerge/2024.01.11-to-develop

This commit is contained in:
CarlNation 2024-01-08 09:12:22 -05:00
commit 734a9419aa
46 changed files with 796 additions and 1559 deletions

View file

@ -89,7 +89,7 @@ export default {
document.getElementById(this.modalId)?.focus();
},
resetButtonStyle() {
this.$refs.modalButtonMain.resetButtonStyle();
this.$refs.modalButtonMain?.resetButtonStyle();
},
onModalOpened() {
this.onModalOpenedCallback?.();

View file

@ -1,7 +1,13 @@
<template>
<div class="row"></div>
<div class="nav-bar container-fluid g-5 my-5 px-0" id="infoBox">
<div class="row d-flex flex-row-reverse justify-content-between align-items-center vw-100">
<div
class="row d-flex justify-content-between vw-100"
:class="[
buttonSize
? 'flex-column align-items-stretch'
: 'flex-row-reverse align-items-center',
]">
<div
:class="['col button-col d-flex', buttonSize ? 'large-button' : 'medium-button']"
id="stacked">
@ -19,7 +25,10 @@
data-bs-dismiss="modal"
v-if="!isSubmitHidden" />
</div>
<div v-if="!isBackButtonHidden" class="col-auto link-col py-1 text-break">
<div
v-if="!isBackButtonHidden"
class="col-auto link-col py-1 text-break"
:class="[buttonSize ? 'back-centered-below mt-5' : '']">
<textLink
linkType="navigation"
:text="backLink"
@ -108,6 +117,12 @@ export default {
.col button {
width: 100%;
}
.back-centered-below {
display: flex;
justify-content: center;
}
@media only screen and (min-width: 340px) {
.col,
.col-auto,
@ -115,6 +130,12 @@ export default {
width: auto;
justify-content: flex-end;
}
.back-centered-below {
display: flex;
justify-content: center;
}
.btn-primary {
width: auto;
}

View file

@ -149,16 +149,26 @@ export async function revalidatePromosAndValidateQueryStringPromo(
} else if (!excludeFromInactivePromoErrorCodes.includes(validatePromoResponse.errorCode)) {
// Save any valid query string promoCode (not applied) to inactivePromos
// in order to not lose the query string promoCode if back button is pressed
const inactivePromoCodes = store.getters.payment.inactivePromos ?? [];
if (!inactivePromoCodes.includes(newPromo)) {
inactivePromoCodes.push(newPromo);
baseMixin.methods.dispatchStoreAction(
storeActions.SAVE_ACTIVE_AND_OR_INACTIVE_PROMOS,
{
inactivePromos: inactivePromoCodes,
},
false
);
const activePromoCodes =
store.getters.order.lineItems.promos?.map((promoObject) => {
return promoObject.promoCode.toUpperCase();
}) ?? [];
const isErrorPromoAlreadyOnOrder = activePromoCodes.includes(
validatePromoResponse.promoCode.toUpperCase()
);
// Do not save an inactivePromo if it is already active on the order
if (!isErrorPromoAlreadyOnOrder) {
const inactivePromoCodes = store.getters.payment.inactivePromos ?? [];
if (!inactivePromoCodes.includes(newPromo)) {
inactivePromoCodes.push(newPromo);
baseMixin.methods.dispatchStoreAction(
storeActions.SAVE_ACTIVE_AND_OR_INACTIVE_PROMOS,
{
inactivePromos: inactivePromoCodes,
},
false
);
}
}
}
}

View file

@ -1,217 +0,0 @@
<template>
<baseInputButton
v-bind="$props"
:buttonWrapperClasses="[
'add-vaps-button w-100 rounded-3 d-flex align-items-center h-100 base-input-button',
{ horizontal: isWide },
]"
v-model="selectedValue">
<div
class="d-flex w-100 align-items-center px-1 h-100 add-vaps-button-content button-content rounded-3"
:class="labelClasses">
<img :id="buttonImageId" :src="buttonImage" :alt="altText" />
<div class="copy-wrapper" :class="isWide ? 'ms-4 me-2' : ''">
<p class="small mb-0" :class="isWide ? 'mt-0' : 'mt-1'">
{{ buttonLabel }}
</p>
<p v-if="buttonLabelSubCopy" class="fs-7 m-0 sub-copy">
{{ buttonLabelSubCopy }}
</p>
</div>
<p class="fs-7 mb-0 link-styling" :class="isWide ? 'mt-0' : 'mt-2'">View details</p>
</div>
</baseInputButton>
</template>
<script>
import baseInputButton from "@/digital-components/base-input-button/base-input-button";
import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
export default {
name: "addVapsButton",
mixins: [inputButtonWrapperMixin],
components: {
baseInputButton,
},
computed: {
labelClasses() {
if (this.isWide) {
let classes = "flex-row py-2 ps-4 pe-4";
if (this.buttonLabelSubCopy) {
classes += " checkboxTop";
}
return classes;
} else {
return "flex-column py-2";
}
},
},
};
</script>
<style lang="scss">
@mixin add-vaps-button-focus($box-shadow-color) {
&:focus-visible + .add-vaps-button-content {
box-shadow: 0 0 0 2.5px $box-shadow-color;
border-radius: 0.5rem;
}
&:focus + .add-vaps-button-content {
box-shadow: 0 0 0 2.5px $box-shadow-color;
border-radius: 0.5rem;
}
&:checked:focus + .add-vaps-button-content {
box-shadow: 0 0 0 2.5px $box-shadow-color;
}
}
.add-vaps-button {
border: 1px solid $gray-500;
&.has-error {
input[type="checkbox"],
input[type="radio"] {
@include add-vaps-button-focus($red);
}
}
img {
height: auto;
width: 2.6rem;
max-width: 100%;
}
.copy-wrapper {
flex: 1 1 auto;
}
input[type="checkbox"],
input[type="radio"] {
position: absolute;
+ .add-vaps-button-content {
outline: none;
display: flex;
position: relative;
justify-content: space-between;
p {
color: $gray-600;
text-align: center;
&.sub-copy {
color: $green;
}
&.link-styling {
font-weight: 500;
color: $blue;
text-decoration: underline;
text-underline-offset: 4px;
}
}
}
@include add-vaps-button-focus($blue);
+ .add-vaps-button-content::before {
content: "";
position: absolute;
display: flex;
margin: 0 auto;
width: 1rem;
height: 1rem;
margin: 3rem 0 0 0;
background: white;
border: 1px solid $gray-500;
border-radius: 2px;
order: 2;
flex-shrink: 0;
color: $gray-600;
}
+ .add-vaps-button-content.checkboxTop::before {
margin: -1.25rem 0.5rem 0 0 !important;
}
+ .add-vaps-button-content.checkboxTop::after {
margin: -1.5rem 0.5rem 0 0 !important;
}
&:checked + .add-vaps-button-content::before {
background: $blue;
}
&:checked + .add-vaps-button-content::after {
content: "";
position: absolute;
margin: 3.2rem 0 0 0;
border-left: 2px solid $white;
border-bottom: 2px solid $white;
height: 6px;
width: 11px;
transform: rotate(-45deg);
z-index: 1;
}
}
input[type="radio"] {
+ .add-vaps-button-content::before {
content: "";
display: none;
}
+ .add-vaps-button-content::after {
content: "";
display: none;
}
+ .add-vaps-button-content {
img {
margin-bottom: 0;
}
}
}
&.horizontal {
input[type="checkbox"],
input[type="radio"] {
+ .add-vaps-button-content::before {
content: "";
position: relative;
margin: 0 0.5rem 0 0;
order: 1;
}
&:checked + .add-vaps-button-content::after {
content: "";
margin: -0.15rem 0 0 0;
left: 1.175rem;
}
&:checked + .add-vaps-button-content {
p {
color: $black;
font-weight: 500;
}
.sub-copy {
color: $gray-600;
font-weight: 400;
}
}
+ .add-vaps-button-content {
outline: none;
min-height: 48px;
color: $gray-600;
img {
margin-bottom: 0;
}
p:not(.link-styling) {
text-align: left;
}
p:not(.link-styling):not(.sub-copy) {
color: $gray-600;
}
}
}
}
}
</style>

View file

@ -1,486 +0,0 @@
<template>
<transition name="fade" mode="out-in">
<div class="add-vaps-buttons" aria-live="polite">
<buttonQuestion
ref="buttonQuestion"
:answers="buttonsToDisplay"
groupName="addVaps"
v-model="buttonsToDisplay"
buttonTypeString="addVapsButton"
:buttonTypeObject="addVapsButton"
:isWide="buttonsToDisplay.length > 1 ? false : true"
@update:modelValue="openModal" />
<contentGroupModal
ref="RainDefenseModal"
cmsWidgetName="RainDefenseModal"
v-if="rainDefenseModal"
@footer-button-action="addRainDefenseToCart('RainDefenseModal', rainDefenseModal)"
footerButtonActionName="footer-button-action">
<p class="price">+${{ rainDefenseModal.listPrice }}</p>
</contentGroupModal>
<contentGroupModal
ref="WipersModal"
cmsWidgetName="WipersModal"
v-if="wipersModal"
@footer-button-action="addWipersToCart('WipersModal', wipersModal)"
footerButtonActionName="footer-button-action">
<div v-if="wipersOffered === wipersOfferedStrings.BOTH" class="wiper-options">
<buttonQuestion
ref="buttonQuestion"
buttonTypeString="checkbox"
class="mt-5"
:answers="wipersOptions"
groupName="wipers-options"
textPosition="text-center"
v-model="selectedWipers"
isMultiSelect
isRequired
validationRules="checkbox-required" />
</div>
<div v-else-if="wipersOffered === wipersOfferedStrings.FRONT" class="wiper-options">
<p class="price">+${{ wipersModal.totalFrontPrice }}</p>
</div>
<div v-else-if="wipersOffered === wipersOfferedStrings.REAR" class="wiper-options">
<p class="price">+${{ wipersModal.totalRearPrice }}</p>
</div>
</contentGroupModal>
</div>
</transition>
</template>
<script>
// COMPONENTS
import buttonQuestion from "@/digital-components/button-question/button-question";
import addVapsButton from "./add-vaps-button/add-vaps-button";
import contentGroupModal from "@/fmg-components/content-group-modal/content-group-modal";
// SUPPORTING
import { partTypeStrings } from "@/constants/part-type-strings";
import { defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
import { processIfStatements } from "@/helpers/cms-content-helper";
import { deepClone } from "@/helpers/object-helper";
// DEFINE VALIDATION RULES
defineRule("checkbox-required", required(errorMessages.OPTION_REQUIRED));
const wipersOfferedStrings = {
FRONT: "FRONT",
REAR: "REAR",
BOTH: "BOTH",
};
export default {
name: "add-vaps-modal-buttons",
props: {
vapsTilesCmsName: String,
availableVaps: Object,
modelValue: Object,
},
data() {
return {
addVapsButton: addVapsButton,
isModalOpened: false,
selectedWipers: this.getSelectedWipers(),
};
},
methods: {
processIfStatements,
openModal(modalName) {
this.selectedWipers = this.getSelectedWipers(); // reset selectedWipers to null upon modal open
this.$refs[modalName].openModal();
},
closeModal(modalName) {
this.$refs[modalName].closeModal();
},
addRainDefenseToCart(modalName, part) {
// Clone vaps array, then replace this.currentCartItems.vaps with it at the end
// in order to successfully trigger parent's watcher
const clonedVaps = this.currentCartItems.vaps.slice(0);
clonedVaps.push(part);
this.currentCartItems.vaps = clonedVaps;
this.closeModal(modalName);
const headline = this.AddedToCartSuccessMessageFromCms;
const message = this.AddRainDefenseSuccessMessageFromCms;
this.$emit("added-to-cart", {
messageHeadline: headline,
messageCopy: message,
type: "alert-success",
isDismissible: true,
});
},
addWipersToCart(modalName, part) {
const itemsForCart = [];
const frontWipersAlreadyInCart = this.currentCartItems.vaps.some((item) => {
return item.partType === part.frontWiperLineItems[0].partType;
});
const rearWipersAlreadyInCart = this.currentCartItems.vaps.some((item) => {
return item.partType === part.rearWiperLineItems[0]?.partType;
});
if (this.wipersOffered === wipersOfferedStrings.BOTH) {
this.selectedWipers.forEach((wiper) => {
itemsForCart.push(wiper);
});
} else {
if (
!frontWipersAlreadyInCart &&
this.wipersOffered === wipersOfferedStrings.FRONT
) {
itemsForCart.push(wipersOfferedStrings.FRONT);
this.selectedWipers = ["FRONT"];
}
if (!rearWipersAlreadyInCart && this.wipersOffered === wipersOfferedStrings.REAR) {
itemsForCart.push(wipersOfferedStrings.REAR);
this.selectedWipers = ["REAR"];
}
}
// Clone vaps array, then replace this.currentCartItems.vaps with it at the end
// in order to successfully trigger parent's watcher
const clonedVaps = this.currentCartItems.vaps.slice(0);
itemsForCart.forEach((type) => {
if (type === wipersOfferedStrings.FRONT) {
part.frontWiperLineItems.forEach((part) => {
clonedVaps.push(part);
});
}
if (type === wipersOfferedStrings.REAR) {
part.rearWiperLineItems.forEach((part) => {
clonedVaps.push(part);
});
}
});
this.currentCartItems.vaps = clonedVaps;
this.closeModal(modalName);
const headline = this.AddedToCartSuccessMessageFromCms;
const message = this.getWipersAddedMessage();
this.$emit("added-to-cart", {
messageHeadline: headline,
messageCopy: message,
type: "alert-success",
isDismissible: true,
});
},
updateWipersOffered(value) {
this.wipersOffered = value;
},
getSelectedWipers() {
return null;
},
getWipersAddedMessage() {
return this.processIfStatements(
this.AddWipersSuccessMessageFromCms,
"custom",
this.getCustomValueFromString
);
},
getCustomValueFromString(str) {
switch (str) {
case "vapsAddedIsFrontWipers":
return (
this.selectedWipers?.length === 1 &&
this.selectedWipers[0] === wipersOfferedStrings.FRONT
);
case "vapsAddedIsRearWipers":
return (
this.selectedWipers?.length === 1 &&
this.selectedWipers[0] === wipersOfferedStrings.REAR
);
case "vapsAddedIsBothWipers":
return this.selectedWipers?.length > 1;
default:
return null;
}
},
getAnswerFromCms(name, answers) {
const answerIndex = answers.findIndex((answer) => {
return answer.Name === name;
});
return answers[answerIndex];
},
},
computed: {
currentCartItems() {
const modelValue = this.modelValue;
if (!modelValue.vaps) modelValue.vaps = [];
return modelValue;
},
rainDefenseModal() {
const lineItem = this.availableVaps.find((item) => {
return item.partType === partTypeStrings.RAIN_DEFENSE;
});
if (lineItem)
lineItem.listPrice = parseFloat(
lineItem?.laborAmount + lineItem?.sellingPrice + lineItem?.kitPrice
).toFixed(2);
return lineItem;
},
wipersModal() {
// create a single wiper item for modal
const frontWiperLineItems = this.availableVaps.filter((item) => {
return item.partType.includes(partTypeStrings.FRONT_WIPER);
});
const rearWiperLineItems = this.availableVaps.filter((item) => {
return item.partType.includes(partTypeStrings.REAR_WIPER);
});
let wipersModal;
let totalFrontPrice = 0;
let totalRearPrice = 0;
// calculate prices to display
frontWiperLineItems?.forEach((item) => {
totalFrontPrice += item?.laborAmount + item?.sellingPrice + item?.kitPrice;
});
rearWiperLineItems?.forEach((item) => {
totalRearPrice += item?.laborAmount + item?.sellingPrice + item?.kitPrice;
});
wipersModal = {
description: "WIPERS ITEM",
partType: "WIPERS",
frontWiperLineItems: frontWiperLineItems,
rearWiperLineItems: rearWiperLineItems,
totalFrontPrice: parseFloat(totalFrontPrice).toFixed(2),
totalRearPrice: parseFloat(totalRearPrice).toFixed(2),
};
return wipersModal;
},
answersCmsData() {
return this.getCmsContent(this.vapsTilesCmsName, "Answers");
},
rainDefenseInCart() {
return this.currentCartItems.vaps?.some((vap) => {
return vap?.partType === partTypeStrings.RAIN_DEFENSE;
});
},
frontWipersInCart() {
return this.currentCartItems.vaps?.some((vap) => {
return vap?.partType?.includes(partTypeStrings.FRONT_WIPER);
});
},
rearWipersInCart() {
return this.currentCartItems.vaps?.some((vap) => {
return vap?.partType?.includes(partTypeStrings.REAR_WIPER);
});
},
wipersOffered() {
let result;
if (!this.frontWipersInCart) {
if (!this.rearWipersInCart && this.wipersModal?.rearWiperLineItems.length > 0) {
result = wipersOfferedStrings.BOTH;
} else if (
!this.frontWipersInCart &&
this.wipersModal?.frontWiperLineItems.length > 0
) {
result = wipersOfferedStrings.FRONT;
}
} else if (!this.rearWipersInCart && this.wipersModal?.rearWiperLineItems.length > 0) {
result = wipersOfferedStrings.REAR;
}
return result;
},
wipersModalDataFromCms() {
return this.getAnswerFromCms("WipersModal", this.answersCmsData);
},
rainDefenseModalDataFromCms() {
return this.getAnswerFromCms("RainDefenseModal", this.answersCmsData);
},
buttonsToDisplay() {
const buttons = [];
if (!this.answersCmsData) return buttons;
const wipersModalData = deepClone(this.wipersModalDataFromCms);
if (this.wipersOffered === wipersOfferedStrings.BOTH) {
// NO REAR WIPERS or FRONT WIPERS IN CART; SHOW COMBO BUTTON
const prices = [
this.wipersModal?.totalFrontPrice,
this.wipersModal?.totalRearPrice,
];
prices.sort((a, b) => a - b);
wipersModalData.SubText = "+$" + prices[0] + " - $" + prices[prices.length - 1];
wipersModalData.Text = wipersModalData.Text?.replaceAll("{custom:WIPERTYPE}", "");
buttons.push(wipersModalData);
}
if (this.wipersOffered === wipersOfferedStrings.FRONT) {
// NO FRONT WIPERS IN CART; SHOW ONLY FRONT WIPERS BUTTON
wipersModalData.SubText = "+$" + this.wipersModal?.totalFrontPrice;
if (this.rearWipersInCart) {
wipersModalData.Text = wipersModalData.Text?.replaceAll(
"{custom:WIPERTYPE}",
wipersOfferedStrings.FRONT.toLowerCase() + " "
);
} else {
wipersModalData.Text = wipersModalData.Text?.replaceAll(
"{custom:WIPERTYPE}",
""
);
}
buttons.push(wipersModalData);
}
if (this.wipersOffered === wipersOfferedStrings.REAR) {
// NO REAR WIPERS IN CART; SHOW ONLY REAR WIPERS BUTTON
wipersModalData.SubText = "+$" + this.wipersModal?.totalRearPrice;
if (this.frontWipersInCart) {
wipersModalData.Text = wipersModalData.Text?.replaceAll(
"{custom:WIPERTYPE}",
wipersOfferedStrings.REAR.toLowerCase() + " "
);
} else {
wipersModalData.Text = wipersModalData.Text?.replaceAll(
"{custom:WIPERTYPE}",
""
);
}
buttons.push(wipersModalData);
}
if (!this.rainDefenseInCart) {
// NO RAIN DEFENSE IN CART; SHOW RAIN DEFENSE BUTTON
const rainDefenseModalData = deepClone(this.rainDefenseModalDataFromCms);
rainDefenseModalData.SubText = "+$" + this.rainDefenseModal?.listPrice;
buttons.push(rainDefenseModalData);
}
return buttons;
},
wipersOfferedStrings() {
return wipersOfferedStrings;
},
wipersOptions() {
const wipersOptions = [];
const wipersFrontCopy = this.getCmsContent(
"AddFrontBladesQuestionWidget",
"QuestionText"
);
const wipersRearCopy = this.getCmsContent(
"AddRearBladesQuestionWidget",
"QuestionText"
);
const frontWipersLineItems = this.wipersModal?.frontWiperLineItems;
const rearWipersLineItems = this.wipersModal?.rearWiperLineItems;
if (frontWipersLineItems) {
const wipersOption = {
buttonLabel: wipersFrontCopy,
groupName: "wipers-options",
value: wipersOfferedStrings.FRONT,
buttonLabelSubCopy: "buttonLabelSubCopy",
buttonBodyCopy: "buttonBodyCopy",
buttonAuxillaryCopy: "buttonAuxillaryCopy",
buttonFooterCopy: "buttonFooterCopy",
additionalButtonData: "+$" + this.wipersModal.totalFrontPrice,
};
wipersOptions.push(wipersOption);
}
if (rearWipersLineItems) {
const wipersOption = {
buttonLabel: wipersRearCopy,
groupName: "wipers-options",
value: wipersOfferedStrings.REAR,
buttonLabelSubCopy: "buttonLabelSubCopy",
buttonBodyCopy: "buttonBodyCopy",
buttonAuxillaryCopy: "buttonAuxillaryCopy",
buttonFooterCopy: "buttonFooterCopy",
additionalButtonData: "+$" + this.wipersModal.totalRearPrice,
};
wipersOptions.push(wipersOption);
}
return wipersOptions;
},
AddedToCartSuccessMessageFromCms() {
return this.getCmsContent("AddedToCartSuccessMessageWidget", "Text");
},
AddRainDefenseSuccessMessageFromCms() {
return this.getCmsContent("AddRainDefenseSuccessMessageWidget", "Text");
},
AddWipersSuccessMessageFromCms() {
return this.getCmsContent("AddWipersSuccessMessageWidget", "Text");
},
},
components: {
buttonQuestion,
contentGroupModal,
},
};
</script>
<style lang="scss">
.add-vaps-buttons {
// necessary overrides for modals
.modal {
&.modal-component {
.modal-dialog {
.modal-content {
.modal-body {
padding-bottom: 1.5rem;
& > img {
margin-bottom: 1.5rem;
line-height: 2;
text-align: center;
}
& > h5 {
text-align: center;
}
p {
margin-bottom: 1rem;
}
p.subheader-text {
text-align: center;
}
p.price {
text-align: center;
color: $green;
font-weight: 500;
}
p:last-child {
margin-bottom: 0;
}
.wiper-options {
.ui-checkbox {
display: block;
& > div.col {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
.checkbox {
display: flex;
justify-content: space-between;
.additional-button-data {
font-size: 0.875rem;
font-weight: 500;
line-height: 1.75;
padding-left: 0.5rem;
color: $green;
}
}
}
p {
margin-bottom: 0;
line-height: 1.75;
}
}
}
}
}
}
}
}
</style>

View file

@ -21,6 +21,10 @@
<hr class="my-0" />
<reviewDropdown ref="reviewDropdown" />
<hr class="my-0" />
<cart
:damage="damageInfo"
:availableVaps="availableVaps"
@ -47,12 +51,6 @@
v-bind:isDismissible="false" />
</div>
<add-vaps-modal-buttons
v-model="lineItems"
:availableVaps="availableVaps"
vapsTilesCmsName="VapsProductTiles"
@added-to-cart="showAlert" />
<paymentMethodQuestion
v-if="!isPiaDisabled"
v-model="paymentMethodInternalModel"
@ -63,6 +61,7 @@
ref="navbar"
:isForwardActionDisabled="!meta.valid"
:isBackButtonHidden="shouldHideBackButton"
buttonSize
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction" />
</div>
@ -79,8 +78,8 @@ import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-heade
import paymentMethodQuestion from "@/layouts/payment-method/payment-method-question/payment-method-question";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import cart from "@/fmg-components/cart/cart";
import reviewDropdown from "@/layouts/payment-method/review-dropdown/review-dropdown";
import promoModalQuestion from "@/layouts/payment-method/promo-modal-question/promo-modal-question";
import addVapsModalButtons from "./add-vaps-modal-buttons/add-vaps-modal-buttons";
import { submitWorkOrder } from "@/helpers/heritage-integration/order-helper.js";
import alert from "@/ux-components/alert/alert";
@ -124,27 +123,31 @@ export default {
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
const lineItemsFromStore = deepClone(store.getters.order.lineItems);
const frontWipersOnOrder =
lineItemsFromStore.vaps.filter(
(wiper) => wiper.partType == partTypeStrings.FRONT_WIPER
) ?? [];
const rearWipersOnOrder =
lineItemsFromStore.vaps.filter(
(wiper) => wiper.partType == partTypeStrings.REAR_WIPER
) ?? [];
const orderHasFrontWipers = frontWipersOnOrder.length > 0;
const orderHasRearWipers = rearWipersOnOrder.length > 0;
const wipersPromise = orderHasFrontWipers
? baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.GET_WIPERS,
{
serviceZipCode: store.getters.order.serviceLocation.zipCode,
carId: store.getters.vehicle.carId,
},
"payment-method"
)
: Promise.resolve([]);
const wipersPromise =
!orderHasFrontWipers || !orderHasRearWipers
? baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.GET_WIPERS,
{
serviceZipCode: store.getters.order.serviceLocation.zipCode,
carId: store.getters.vehicle.carId,
},
"payment-method"
)
: Promise.resolve([]);
const rainDefensePromise = baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.GET_RAIN_DEFENSE,
@ -152,6 +155,8 @@ export default {
"payment-method"
);
const reviewDropdownPromise = reviewDropdown.methods.loadInitialData();
const promiseResultMap = [
{
resultKey: "cmsContent",
@ -165,6 +170,10 @@ export default {
resultKey: "rainDefense",
promise: rainDefensePromise,
},
{
resultKey: "reviewDropdownData",
promise: reviewDropdownPromise,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
@ -269,6 +278,8 @@ export default {
);
vm.updateFooterButtonText(vm.customCtaCopy);
vm.$refs.reviewDropdown.initializeComponent(resultMap.reviewDropdownData);
if (revalidatePromoResponse) {
const revalidateAlerts = buildToastMessagesFromRevalidateOrValidatePromoResponse(
revalidatePromoResponse,
@ -352,17 +363,10 @@ export default {
);
},
getPaymentMethodFromStore() {
const isPia = store.getters.order.payment.isPia;
const piaType = store.getters.order.payment.piaType;
const isPia = store.getters.order.payment.isPia && !!piaType;
if (isPia === null || isPia === undefined) {
return null;
}
if (isPia) {
return store.getters.order.payment.piaType;
} else {
return paymentMethods.LATER;
}
return isPia ? piaType : paymentMethods.LATER;
},
updateFooterButtonText(newValue) {
this.$refs.navbar.updateButtonText(newValue);
@ -495,9 +499,6 @@ export default {
this.$route
);
},
showAlert(alert) {
this.$refs.funnelHeader.pushGlobalAlert(alert, true);
},
},
computed: {
damageInfo() {
@ -591,9 +592,9 @@ export default {
loadingModal,
cart,
alert,
addVapsModalButtons,
paymentMethodQuestion,
promoModalQuestion,
reviewDropdown,
},
};
</script>

View file

@ -1,7 +1,7 @@
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import store from "@/store";
import reviewBlock from "@/layouts/review/review-block/review-block";
import reviewBlock from "@/layouts/payment-method/review-dropdown/review-block/review-block";
jest.mock("@/helpers/cms-content-helper", () => ({
fetchCmsContentForPage: () => Promise.resolve("content"),

View file

@ -6,17 +6,6 @@
:customText="customHeaderText"
typeStyle="body small bold dark"
marginTopSizeOverride="0" />
<textLink
linkType="textSmall"
text="Edit"
class="ml-auto"
useLoadingModal
@click-event="linkClicked"
href="javascript:void(0)">
<template v-slot:after-text v-if="editScreenReaderTextCmsWidgetName">
<span class="sr-only"> {{ screenReaderOnlyText }} </span>
</template>
</textLink>
</div>
<div
class="small review-block-content"
@ -38,24 +27,13 @@ export default {
customHeaderText: String,
// Specifically an array of strings.
content: Array,
editScreenReaderTextCmsWidgetName: String,
},
data() {
return {};
},
computed: {
screenReaderOnlyText() {
return this.getCmsContent(this.editScreenReaderTextCmsWidgetName, "Text");
},
},
methods: {
linkClicked() {
this.$emit("edit-clicked");
},
},
methods: {},
components: {
textBlock,
textLink,
},
};
</script>

View file

@ -0,0 +1,174 @@
<template>
<div>
<div
class="row review-toggle flex align-items-center pt-4"
:class="[isExpanded ? 'expanded' : '']"
@click="toggleIsExpanded">
<a
aria-label="expand appointment details"
href="javascript:void(0)"
class="col d-flex justify-content-between py-0">
<span class="label">Appointment details</span>
</a>
</div>
<div class="review-table px-4">
<vehicleReview cmsWidgetName="VehicleReviewWidget" :vehicle="vehicleInfo" />
<hr class="my-0" />
<damageReview
cmsWidgetName="DamageReviewWidget"
damageLocationsWidgetName="DamageLocationsWidget"
:damage="damageInfo" />
<hr class="my-0" />
<servicePackageReview
ref="servicePackageReview"
servicePackageOptionsCmsName="ServicePackageTitle"
defaultPackageItemsCmsName="DefaultPackageItemDescriptions"
vapsItemsCmsName="VapsItemDescriptions"
:damage="damageInfo"
:lineItems="lineItems" />
<hr class="my-0" />
<serviceLocationReview
cmsWidgetName="ServiceLocationTitleWidget"
:serviceLocation="serviceLocationInfo" />
<hr class="my-0" />
<scheduleReview cmsWidgetName="ScheduleWidget" :appointmentType="appointmentType" />
<hr class="my-0" />
<customerReview cmsWidgetName="CustomerReviewWidget" :customer="customerInfo" />
</div>
</div>
</template>
<script>
import textBlock from "@/digital-components/text-block/text-block";
import customerReview from "@/layouts/payment-method/review-dropdown/review-sections/customer-review/customer-review";
import damageReview from "@/layouts/payment-method/review-dropdown/review-sections/damage-review/damage-review";
import scheduleReview from "@/layouts/payment-method/review-dropdown/review-sections/schedule-review/schedule-review";
import serviceLocationReview from "@/layouts/payment-method/review-dropdown/review-sections/service-location-review/service-location-review";
import servicePackageReview from "@/layouts/payment-method/review-dropdown/review-sections/service-package-review/service-package-review";
import vehicleReview from "@/layouts/payment-method/review-dropdown/review-sections/vehicle-review/vehicle-review";
import { settleAllPromises } from "@/helpers/layout-helper";
export default {
name: "review-dropdown",
props: {},
data() {
return {
isExpanded: false,
};
},
methods: {
async loadInitialData() {
const servicePackageInitialDataPromise = servicePackageReview.methods.loadInitialData();
const promiseResultMap = [
{
resultKey: "servicePackageData",
promise: servicePackageInitialDataPromise,
},
];
return settleAllPromises(promiseResultMap);
},
initializeComponent(apiResponses) {
this.$refs.servicePackageReview.initializeComponent(apiResponses.servicePackageData);
},
toggleIsExpanded() {
this.isExpanded = !this.isExpanded;
},
},
computed: {
vehicleInfo() {
return this.$store.getters.vehicle;
},
damageInfo() {
return this.$store.getters.damage;
},
lineItems() {
return this.$store.getters.lineItems;
},
serviceLocationInfo() {
return this.$store.getters.order.serviceLocation;
},
appointmentType() {
return this.$store.getters.order.serviceLocation.appointmentType;
},
customerInfo() {
return this.$store.getters.order.customer;
},
},
components: {
customerReview,
damageReview,
scheduleReview,
serviceLocationReview,
servicePackageReview,
vehicleReview,
},
};
</script>
<style lang="scss" scoped>
.dark-header {
color: $black;
line-height: 1.6;
}
.review-table {
max-height: 0;
transition: all 350ms ease-in;
overflow: hidden;
visibility: hidden;
}
.review-toggle {
margin-bottom: 1rem;
&.expanded {
margin-bottom: 0;
}
&:after {
content: "";
transition: all 0.5s ease;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 8.9' xml:space='preserve'%3e%3cpath d='M8 8.9c-.2 0-.5-.1-.6-.3L.3 1.5C.1 1.4 0 1.1 0 .9 0 .7.1.4.3.3.4.1.7 0 .9 0c.2 0 .5.1.6.3L8 6.7 14.5.2c.1-.1.4-.2.6-.2.2 0 .5.1.6.3s.3.4.3.6c0 .2-.1.5-.3.6L8.6 8.6c-.1.2-.4.3-.6.3z' fill='%231474a2'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right center;
width: 16px;
height: 9px;
display: inline-flex;
position: relative;
right: 0.75rem;
margin: 0.5rem 0 0.5rem 1rem;
cursor: pointer;
}
&.expanded:after {
transform: rotate(180deg);
}
&.expanded + .review-table {
max-height: 800px;
transition: all 150ms ease-in;
overflow: hidden;
visibility: visible;
}
a {
text-decoration: none;
}
.label {
color: $black;
font-weight: 500;
line-height: 1.625;
}
}
</style>

View file

@ -1,7 +1,7 @@
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import customerReview from "@/layouts/review/review-sections/customer-review/customer-review";
import customerReview from "@/layouts/payment-method/review-dropdown/review-sections/customer-review/customer-review";
const testConstants = {
cms: {

View file

@ -1,13 +1,9 @@
<template>
<reviewBlock
editScreenReaderTextCmsWidgetName="EditContactDetailsScreenReader"
:customHeaderText="header"
:content="displayContent"
@edit-clicked="editClicked" />
<reviewBlock :customHeaderText="header" :content="displayContent" @edit-clicked="editClicked" />
</template>
<script>
import reviewBlock from "@/layouts/review/review-block/review-block";
import reviewBlock from "@/layouts/payment-method/review-dropdown/review-block/review-block";
export default {
name: "customer-review",

View file

@ -1,7 +1,7 @@
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import damageReview from "@/layouts/review/review-sections/damage-review/damage-review";
import damageReview from "@/layouts/payment-method/review-dropdown/review-sections/damage-review/damage-review";
import { damageLocationsSelected as glassConstants } from "@/constants/damage-locations-selected";
const testConstants = {

View file

@ -1,13 +1,12 @@
<template>
<reviewBlock
editScreenReaderTextCmsWidgetName="EditDamageScreenReader"
:headerCmsWidgetName="cmsWidgetName"
:content="displayContent"
@edit-clicked="editClicked" />
</template>
<script>
import reviewBlock from "@/layouts/review/review-block/review-block";
import reviewBlock from "@/layouts/payment-method/review-dropdown/review-block/review-block";
import { damageLocationsSelected } from "@/constants/damage-locations-selected.js";
export default {

View file

@ -1,13 +1,12 @@
<template>
<reviewBlock
editScreenReaderTextCmsWidgetName="EditAppointmentScreenReader"
:customHeaderText="headerText"
:content="displayContent"
@edit-clicked="editClicked" />
</template>
<script>
import reviewBlock from "@/layouts/review/review-block/review-block";
import reviewBlock from "@/layouts/payment-method/review-dropdown/review-block/review-block";
export default {
name: "schedule-review",

View file

@ -1,6 +1,6 @@
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import serviceLocationReview from "@/layouts/review/review-sections/service-location-review/service-location-review";
import serviceLocationReview from "@/layouts/payment-method/review-dropdown/review-sections/service-location-review/service-location-review";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";

View file

@ -1,13 +1,12 @@
<template>
<reviewBlock
editScreenReaderTextCmsWidgetName="EditLocationScreenReader"
:headerCmsWidgetName="cmsWidgetName"
:content="displayContent"
@edit-clicked="editClicked" />
</template>
<script>
import reviewBlock from "@/layouts/review/review-block/review-block";
import reviewBlock from "@/layouts/payment-method/review-dropdown/review-block/review-block";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
export default {

View file

@ -1,6 +1,6 @@
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import servicePackageReview from "@/layouts/review/review-sections/service-package-review/service-package-review";
import servicePackageReview from "@/layouts/payment-method/review-dropdown/review-sections/service-package-review/service-package-review";
import { packageNames } from "@/constants/package-names";
import { partTypeStrings } from "@/constants/part-type-strings";

View file

@ -1,13 +1,12 @@
<template>
<reviewBlock
editScreenReaderTextCmsWidgetName="EditServiceTypeScreenReader"
:headerCmsWidgetName="packageNameWidget"
:content="displayContent"
@edit-clicked="editClicked" />
</template>
<script>
import reviewBlock from "@/layouts/review/review-block/review-block";
import reviewBlock from "@/layouts/payment-method/review-dropdown/review-block/review-block";
import {
getHighestFullySatisfiedTier,
containsLineItemWithPartType,

View file

@ -1,7 +1,7 @@
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import store from "@/store";
import vehicleReview from "@/layouts/review/review-sections/vehicle-review/vehicle-review";
import vehicleReview from "@/layouts/payment-method/review-dropdown/review-sections/vehicle-review/vehicle-review";
jest.mock("@/helpers/cms-content-helper", () => ({
fetchCmsContentForPage: () => Promise.resolve("content"),

View file

@ -1,13 +1,12 @@
<template>
<reviewBlock
editScreenReaderTextCmsWidgetName="EditVehicleScreenReader"
:headerCmsWidgetName="cmsWidgetName"
:content="displayContent"
@edit-clicked="editClicked" />
</template>
<script>
import reviewBlock from "@/layouts/review/review-block/review-block";
import reviewBlock from "@/layouts/payment-method/review-dropdown/review-block/review-block";
export default {
name: "vehicle-review",

View file

@ -24,9 +24,26 @@ export default {
if (piaError) {
console.log("Error during payment: " + piaError);
this.$router.navigateWithoutSaving(this.navigationScenarios.PIA_ERROR, this.$route, {
[queryStrings.DISPLAY_PIA_ALERT]: true,
});
const paymentPageNavScenario =
store.getters.order.payment.piaType === paymentMethods.CREDIT_CARD ||
store.getters.order.payment.piaType === paymentMethods.AFTERPAY;
if (paymentPageNavScenario) {
this.$router.navigateWithoutSaving(
this.navigationScenarios.PIA_CC_ERROR,
this.$route,
{
[queryStrings.DISPLAY_PIA_ALERT]: store.getters.order.payment.piaType,
}
);
} else {
this.$router.navigateWithoutSaving(
this.navigationScenarios.PIA_ERROR,
this.$route,
{
[queryStrings.DISPLAY_PIA_ALERT]: true,
}
);
}
} else {
switch (store.getters.order.payment.piaType) {
case paymentMethods.CREDIT_CARD:

View file

@ -589,6 +589,7 @@ describe("payment.vue", () => {
function setupMocks({ customMountOptions }) {
const mountOptions = getMountOptions({
...customMountOptions,
route: { query: { fmgPage: "page-name" }, params: {} },
});
// set all mock stuff

View file

@ -10,6 +10,26 @@
<div class="row justify-content-center">
<div class="col-md-6 col-xl-4 p-0">
<div>
<alert
ref="piaCCErrorAlert"
class="my-4"
v-if="shouldDisplayPiaCCAlert"
cmsWidgetName="PIACCErrorAlertWidget"
alertClass="alert-danger"
v-bind:isDismissible="false" />
</div>
<div>
<alert
ref="piaAPErrorAlert"
class="my-4"
v-if="shouldDisplayPiaAPAlert"
cmsWidgetName="PIAAPErrorAlertWidget"
alertClass="alert-danger"
v-bind:isDismissible="false" />
</div>
<div id="card-container">
<iframe
v-resize
@ -176,6 +196,7 @@ import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import navbar from "@/fmg-components/nav-bar/nav-bar";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import cart from "@/fmg-components/cart/cart";
import alert from "@/ux-components/alert/alert";
import { storeActions } from "@/constants/store-actions";
import store from "@/store";
@ -197,6 +218,7 @@ import { getQuerystringParameter } from "@/helpers/querystring-helper";
import { deepClone } from "@/helpers/object-helper";
import iframeResize from "../../../node_modules/iframe-resizer/js/iframeResizer.js";
import { routerParams } from "@/router/router-constants/router-params";
export default {
name: "payment",
@ -406,6 +428,12 @@ export default {
}
return false;
},
shouldDisplayPiaCCAlert() {
return this.shouldDisplayPiaAlert(paymentMethods.CREDIT_CARD);
},
shouldDisplayPiaAPAlert() {
return this.shouldDisplayPiaAlert(paymentMethods.AFTERPAY);
},
},
methods: {
arePagePrerequisitesValid() {
@ -607,12 +635,19 @@ export default {
event.stopPropagation();
}
},
shouldDisplayPiaAlert(payMethod) {
return (
this.$route.query[queryStrings.DISPLAY_PIA_ALERT] === payMethod ||
this.$route.params[routerParams.DISPLAY_PIA_ALERT] === payMethod
);
},
},
components: {
funnelHeader,
navbar,
loadingModal,
cart,
alert,
},
};
</script>

View file

@ -12,7 +12,7 @@
class="strikethrough-price"
v-if="shouldDisplayStrikeThroughPrice"
v-html="this.additionalButtonData.strikeThroughPrice"></span>
<span v-html="this.buttonAuxillaryCopy"></span>
<span v-html="this.buttonAuxillaryCopy"></span>*
</span>
</p>
<p

View file

@ -1,362 +0,0 @@
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import store from "@/store";
import review from "@/layouts/review/review";
const testConstants = {};
jest.mock("@/store", () => ({
commit: jest.fn(),
dispatch: jest.fn(),
}));
describe("Review Page", () => {
beforeEach(() => {
store.getters = {
order: {
vehicle: {
year: "2020",
make: "Acura",
model: "MDX",
style: "4 door sedan",
},
damage: {
isRepair: false,
numberOfChips: 2,
glassToReplace: ["dummy location value"],
},
lineItems: {
glassParts: ["dummy part value"],
supportingItems: ["dummy supporting item"],
vaps: ["dummy vap"],
},
serviceLocation: {
address: "address 1",
address2: "address 2",
city: "city",
state: "state",
zipCode: "zip code",
appointmentType: "Mobile",
provider: {
providerNumber: 1,
address: {
streetAddress: "provider address 1",
city: "provider city",
state: "provider state",
zipCode: "provider zip code",
},
},
},
schedule: {
date: "date",
startTime: "start",
endTime: "end",
jobMinMinutes: "30",
jobMaxMinutes: "45",
},
customer: {
firstName: "first name",
lastName: "last name",
emailAddress: "builddigitaltest@safelite.com",
phoneNumber: "555-555-5555",
isSmsOptIn: true,
},
},
};
});
describe("arePagePrerequisitesValid", () => {
test("Returns true for baseline valid state", () => {
// Arrange
const { wrapper } = setupMocks({});
// Act
const isValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(isValid).toBe(true);
});
test("Returns false for empty state", () => {
// Arrange
store.getters.order = {
vehicle: {
year: null,
make: null,
model: null,
style: null,
carId: null,
category: null,
vin: null,
imageUrl: null,
imageVifNumber: null,
imageColor: null,
registration: {
licensePlate: null,
},
},
serviceLocation: {
address: null,
address2: null,
city: null,
state: null,
zipCode: null,
zipCodeCtu: null,
appointmentType: null,
isVehicleProtected: null,
provider: {
providerNumber: null,
address: {
streetAddress: null,
city: null,
state: null,
zipCode: null,
zipCodeCtu: null,
},
},
techNotes: null,
},
customer: {
firstName: null,
lastName: null,
emailAddress: null,
phoneNumber: null,
isSmsOptIn: null,
},
damage: {
isRepair: null,
numberOfChips: null,
glassToReplace: null,
partQuestionAnswers: null,
moldingQuestionAnswers: null,
capabilityQuestionAnswers: null,
},
lineItems: {
glassParts: null,
supportingItems: null,
vaps: null,
serverData: null,
},
payment: {
isInsurance: null,
insuranceCoverage: {
isVerified: null,
coverageStatus: null,
},
parentAccountNumber: 0,
},
schedule: {
date: null,
startTime: null,
endTime: null,
routeCode: null,
jobMaxMinutes: null,
jobMinMinutes: null,
},
referralNumber: null,
referralSequenceNumber: null,
referralDate: null,
referralCorrelationId: null,
eon: null,
};
const { wrapper } = setupMocks({});
// Act
const isValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(isValid).toBe(false);
});
describe("Damage requirements", () => {
test("Accepts null glassToReplace when is repair", () => {
// Arrange
store.getters.order.damage.isRepair = true;
store.getters.order.damage.glassToReplace = null;
store.getters.order.damage.numberOfChips = 1;
const { wrapper } = setupMocks({});
// Act
const isValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(isValid).toBe(true);
});
test("Rejects 0 chips when repair", () => {
// Arrange
store.getters.order.damage.isRepair = true;
store.getters.order.damage.numberOfChips = 0;
const { wrapper } = setupMocks({});
// Act
const isValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(isValid).toBe(false);
});
test("Rejects null chips when repair", () => {
// Arrange
store.getters.order.damage.isRepair = true;
store.getters.order.damage.numberOfChips = null;
const { wrapper } = setupMocks({});
// Act
const isValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(isValid).toBe(false);
});
test("Accepts null chips when not repair", () => {
// Arrange
store.getters.order.damage.isRepair = false;
store.getters.order.damage.numberOfChips = null;
const { wrapper } = setupMocks({});
// Act
const isValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(isValid).toBe(true);
});
test("Rejects empty glassToReplace when not repair", () => {
// Arrange
store.getters.order.damage.isRepair = false;
store.getters.order.damage.glassToReplace = null;
const { wrapper } = setupMocks({});
// Act
const isValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(isValid).toBe(false);
});
test("Rejects null glassToReplace when not repair", () => {
// Arrange
store.getters.order.damage.isRepair = false;
store.getters.order.damage.glassToReplace = [];
const { wrapper } = setupMocks({});
// Act
const isValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(isValid).toBe(false);
});
});
describe("Package requirements", () => {
test("Accepts null glassParts when is repair", () => {
// Arrange
store.getters.order.damage.isRepair = true;
store.getters.order.lineItems.glassParts = null;
const { wrapper } = setupMocks({});
// Act
const isValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(isValid).toBe(true);
});
test("Rejects null glassParts when not repair", () => {
// Arrange
store.getters.order.damage.isRepair = false;
store.getters.order.lineItems.glassParts = null;
const { wrapper } = setupMocks({});
// Act
const isValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(isValid).toBe(false);
});
});
describe("Service Location requirements", () => {
test("Accepts null provider address when mobile appointment", () => {
// Arrange
store.getters.order.serviceLocation.appointmentType = "Mobile";
store.getters.order.serviceLocation.provider.address = {};
const { wrapper } = setupMocks({});
// Act
const isValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(isValid).toBe(true);
});
test("Reject null provider address when non-mobile appointment", () => {
// Arrange
store.getters.order.serviceLocation.appointmentType = "Inshop";
store.getters.order.serviceLocation.provider.address = {};
const { wrapper } = setupMocks({});
// Act
const isValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(isValid).toBe(false);
});
test("Accepts null service location address when non-mobile appointment", () => {
// Arrange
store.getters.order.serviceLocation.appointmentType = "Inshop";
store.getters.order.serviceLocation.address = null;
store.getters.order.serviceLocation.address2 = null;
store.getters.order.serviceLocation.zipCode = null;
store.getters.order.serviceLocation.city = null;
store.getters.order.serviceLocation.state = null;
const { wrapper } = setupMocks({});
// Act
const isValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(isValid).toBe(true);
});
test("Rejects null service location address when mobile appointment", () => {
// Arrange
store.getters.order.serviceLocation.appointmentType = "Mobile";
store.getters.order.serviceLocation.address = null;
store.getters.order.serviceLocation.address2 = null;
store.getters.order.serviceLocation.zipCode = null;
store.getters.order.serviceLocation.city = null;
store.getters.order.serviceLocation.state = null;
const { wrapper } = setupMocks({});
// Act
const isValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(isValid).toBe(false);
});
});
});
});
function setupMocks(customMountOptions) {
customMountOptions.store = store;
const mountOptions = getMountOptions(customMountOptions);
const mockMixin = {
methods: {
getCmsContent: jest.fn((widgetName, cmsFieldName) => {
return `${widgetName} ${cmsFieldName}`;
}),
},
};
mountOptions.global.mixins = [mockMixin];
const wrapper = shallowMount(review, mountOptions);
wrapper.vm.setCmsContent = jest.fn();
return { wrapper };
}

View file

@ -1,330 +0,0 @@
<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm">
<!-- When customer-details is added: v-slot="{ meta }" -->
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-md-6">
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-6 col-xl-4">
<vehicleBanner
cmsWidgetName="VehicleBannerWidget"
:displayGenericVehicleImage="false" />
<textBlock
:customText="subHeaderTitle"
typeStyle="h5"
justifyText="center"
margin="mt-1"
class="dark-header" />
<textBlock
:customText="subHeaderBody"
typeStyle="body"
justifyText="left"
margin="mt-0 mb-2" />
<buttonMain
ref="buttonMain"
isPrimary
:buttonText="forwardButtonText"
loaderColor="white"
class="mb-2 w-100"
@click-event="forwardButtonAction" />
<div>
<hr />
</div>
<textBlock
customText="Appointment Details"
typeStyle="label bold"
justifyText="justify-text-left"
margin="mt-0"
class="dark-header" />
<div class="px-4">
<vehicleReview
cmsWidgetName="VehicleReviewWidget"
:vehicle="vehicleInfo"
@edit-clicked="editVehicle" />
<hr class="my-0" />
<damageReview
cmsWidgetName="DamageReviewWidget"
damageLocationsWidgetName="DamageLocationsWidget"
:damage="damageInfo"
@edit-clicked="editDamage" />
<hr class="my-0" />
<servicePackageReview
ref="servicePackageReview"
servicePackageOptionsCmsName="ServicePackageTitle"
defaultPackageItemsCmsName="DefaultPackageItemDescriptions"
vapsItemsCmsName="VapsItemDescriptions"
:damage="damageInfo"
:lineItems="lineItems"
@edit-clicked="editServicePackage" />
<hr class="my-0" />
<serviceLocationReview
cmsWidgetName="ServiceLocationTitleWidget"
:serviceLocation="serviceLocationInfo"
@edit-clicked="editServiceLocation" />
<hr class="my-0" />
<scheduleReview
cmsWidgetName="ScheduleWidget"
:appointmentType="appointmentType"
@edit-clicked="editSchedule" />
<hr class="my-0" />
<customerReview
cmsWidgetName="CustomerReviewWidget"
:customer="customerInfo"
@edit-clicked="editCustomerDetails" />
</div>
<div>
<hr class="my-0" />
</div>
<navbar
cmsWidgetName="FunnelFooterWidget"
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction" />
</div>
</div>
</div>
</Form>
</template>
<script>
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import navbar from "@/fmg-components/nav-bar/nav-bar";
import vehicleBanner from "@/fmg-components/vehicle-banner/vehicle-banner";
import buttonMain from "@/ux-components/button-main/button-main";
import textBlock from "@/digital-components/text-block/text-block";
import vehicleReview from "@/layouts/review/review-sections/vehicle-review/vehicle-review";
import damageReview from "@/layouts/review/review-sections/damage-review/damage-review";
import servicePackageReview from "@/layouts/review/review-sections/service-package-review/service-package-review";
import serviceLocationReview from "@/layouts/review/review-sections/service-location-review/service-location-review";
import scheduleReview from "@/layouts/review/review-sections/schedule-review/schedule-review";
import customerReview from "@/layouts/review/review-sections/customer-review/customer-review";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
import store from "@/store";
// Validation
import { Form } from "vee-validate";
export default {
name: "review",
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
const servicePackageInitialDataPromise = servicePackageReview.methods.loadInitialData();
// Settle promises and get results
const promiseResultMap = [
{
resultKey: "cmsContent",
promise: cmsContentPromise,
},
{
resultKey: "servicePackageData",
promise: servicePackageInitialDataPromise,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.$refs.servicePackageReview.initializeComponent(resultMap.servicePackageData);
});
},
data() {
return {};
},
methods: {
arePagePrerequisitesValid() {
// Vehicle
const vehicle = store.getters.order.vehicle;
const vehicleReqs = !!(vehicle.year && vehicle.make && vehicle.model && vehicle.style);
// Damage
const damage = store.getters.order.damage;
const damageReqs = !!(
(damage.isRepair && damage.numberOfChips) ||
(!damage.isRepair && damage.glassToReplace?.length)
);
// Service Package
const lineItems = store.getters.order.lineItems;
// damageReqs handles checking for damage, even though it is also required for this section.
const packageReqs = !!(
(damage.isRepair || lineItems.glassParts) &&
lineItems.supportingItems
);
// Service Location
const serviceLocation = store.getters.order.serviceLocation;
const mobileReqs = !!(
serviceLocation.address &&
serviceLocation.city &&
serviceLocation.state &&
serviceLocation.zipCode
);
const providerLocation = serviceLocation.provider.address;
const dropOffInshopReqs = !!(
providerLocation.streetAddress &&
providerLocation.city &&
providerLocation.state &&
providerLocation.zipCode
);
const isMobile = serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE;
const serviceLocationReqs =
(isMobile && mobileReqs) || (!isMobile && dropOffInshopReqs);
// Schedule
const schedule = store.getters.order.schedule;
const scheduleReqs = !!(
schedule.date &&
schedule.startTime &&
schedule.endTime &&
schedule.jobMaxMinutes &&
schedule.jobMinMinutes
);
// Customer
const customer = store.getters.order.customer;
const customerReqs = !!(
customer.firstName &&
customer.lastName &&
customer.phoneNumber &&
customer.emailAddress
);
return (
vehicleReqs &&
damageReqs &&
packageReqs &&
serviceLocationReqs &&
scheduleReqs &&
customerReqs
);
},
backButtonAction() {
this.$router.navigateWithoutSaving(this.navigationScenarios.CLICKED_BACK, this.$route);
},
forwardButtonAction() {
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_FORWARD,
this.$route
);
},
editVehicle() {
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_VEHICLE_EDIT,
this.$route
);
},
editDamage() {
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_DAMAGE_EDIT,
this.$route
);
},
editServicePackage() {
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_SERVICE_PACKAGE_EDIT,
this.$route
);
},
editServiceLocation() {
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_SERVICE_LOCATION_EDIT,
this.$route
);
},
editSchedule() {
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_SCHEDULE_EDIT,
this.$route
);
},
editCustomerDetails() {
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_CUSTOMER_EDIT,
this.$route
);
},
},
computed: {
subHeaderTitle() {
return this.getCmsContent("FunnelSubHeaderWidget", "HeaderText");
},
subHeaderBody() {
return this.getCmsContent("FunnelSubHeaderWidget", "BodyText");
},
forwardButtonText() {
return this.getCmsContent("FunnelFooterWidget", "ForwardButtonText");
},
vehicleInfo() {
return this.$store.getters.vehicle;
},
damageInfo() {
return this.$store.getters.damage;
},
lineItems() {
return this.$store.getters.lineItems;
},
serviceLocationInfo() {
return this.$store.getters.order.serviceLocation;
},
appointmentType() {
return this.$store.getters.order.serviceLocation.appointmentType;
},
customerInfo() {
return this.$store.getters.order.customer;
},
},
components: {
funnelHeader,
navbar,
vehicleBanner,
buttonMain,
textBlock,
vehicleReview,
damageReview,
servicePackageReview,
serviceLocationReview,
scheduleReview,
customerReview,
Form,
},
};
</script>
<style lang="scss" scoped>
.dark-header {
color: $black;
line-height: 1.6;
}
</style>

View file

@ -48,7 +48,8 @@
selectableDatesData.estimatedServiceMinutesMaximum
"
@time-slot-modal-closed="timeSlotModalClosed"
validationRules="time-slot-selection-required" />
validationRules="time-slot-selection-required"
@TimeSlotSelected="forwardButtonAction" />
<navbar
cmsWidgetName="FunnelFooterWidget"
ref="navbar"

View file

@ -97,6 +97,7 @@ describe("time-slot-modal-question.vue", () => {
//Assert
expect(wrapper.emitted()["update:modelValue"]).toEqual(expectedEmit);
expect(wrapper.emitted()).toHaveProperty("TimeSlotSelected");
});
});

View file

@ -82,7 +82,7 @@ defineRule("time-slot-required", required(errorMessages.OPTION_REQUIRED));
export default {
name: "time-slot-modal-question",
emits: ["update:modelValue"],
emits: ["update:modelValue", "TimeSlotSelected"],
props: {
modelValue: {
type: Object,
@ -343,13 +343,15 @@ export default {
},
onModalClosed() {
this.$emit("time-slot-modal-closed");
this.selectedRouteCode = this.getSelectedRouteCode();
this.modal.resetForm();
},
async setSelectedTimeSlot() {
this.$emit(
"update:modelValue",
this.getSelectedTimeSlotInfoObject(this.selectedRouteCode)
);
this.closeModal();
this.$emit("TimeSlotSelected");
},
getRelevantDropOffCmsWidgetNameForSelectedTimeSlot(
selectedRouteCode,
@ -492,6 +494,9 @@ export default {
};
},
},
unmounted() {
if (this.isModalOpened) this.closeModal();
},
watch: {
modelValue: {
handler(newValue) {

View file

@ -337,7 +337,6 @@ describe("mobile-location-modal-questions.vue", () => {
// Assert
expect(wrapper.vm.displayInvalidZipAlert).toBe(true);
});
it("Should display mismatch state and zip alert for non-matching state/zip for mobile address zip inputs", async () => {
// Arrange
const mobileLocationQuestions = {

View file

@ -480,19 +480,6 @@ export default {
async forwardButtonAction() {
this.navigatingForward = true;
// clear items not needed for appointmentType
if (this.selectedAppointmentType == AppointmentTypeStrings.IN_SHOP) {
// if we have a provider.zipCodeCtu that's different than the servicelocation.zipCodeCtu then they
// may have selected a shop in a different ctu. change the servicelocation zip/ctu if different
if (this.zipCodeCtu != this.selectedProvider.address.zipCodeCtu) {
this.zipCodeCtu = this.selectedProvider.address.zipCodeCtu;
this.zipCode = this.selectedProvider.address.zipCode;
}
this.city = null;
this.streetAddress = null;
}
if (
this.selectedAppointmentType == AppointmentTypeStrings.MOBILE &&
this.selectedProvider &&
@ -505,6 +492,18 @@ export default {
this.selectedProvider.address.zipCodeCtu = null;
}
var ctu = this.zipCodeCtu;
if (this.selectedAppointmentType == AppointmentTypeStrings.IN_SHOP) {
// if we have a provider.zipCodeCtu that's different than the servicelocation.zipCodeCtu then they
// may have selected a shop in a different ctu. change the servicelocation zip/ctu if different
if (this.zipCodeCtu != this.selectedProvider.address.zipCodeCtu) {
ctu = this.selectedProvider.address.zipCodeCtu;
}
this.city = null;
this.streetAddress = null;
}
await this.dispatchStoreAction(
this.storeActions.SAVE_SERVICE_LOCATION,
{
@ -513,7 +512,7 @@ export default {
city: this.city,
state: this.state,
zipCode: this.zipCode,
zipCodeCtu: this.zipCodeCtu,
zipCodeCtu: ctu,
appointmentType: this.selectedAppointmentType,
isVehicleProtected: this.isVehicleProtected,
provider: {

View file

@ -30,6 +30,18 @@ describe("vehicle.vue", () => {
//Assert
expect(arePagePrerequisitesValid).toBe(true);
});
test("if the vehicle is not serviceable displays the displayNoServiceAlert", async () => {
//Arrange
const { wrapper } = setupMocks({
canSafeliteService: false,
});
//Act
await wrapper.vm.getVehicleDetails();
//Assert
expect(wrapper.findComponent({ ref: "AlertNoService" }).isVisible()).toBe(true);
});
});
describe("vehicle.vue", () => {
describe("navigation", () => {

View file

@ -56,11 +56,20 @@
:displayUnmatchedVehicleIcon="unmatchedVehicleIcon"
class="mt-5 vehicle-footer"
ref="banner" />
<alert
ref="AlertNoService"
v-if="displayNoServiceAlert"
class="mt-4 alert"
cmsWidgetName="AlertNoServiceWidget"
alertClass="alert-danger"
:isDismissible="false" />
<navbar
cmsWidgetName="FunnelFooterWidget"
@ForwardClicked="forwardButtonAction"
:isForwardActionDisabled="!meta.valid || !allDataRetrieved"
:isForwardActionDisabled="
!meta.valid || !allDataRetrieved || displayNoServiceAlert
"
ref="navbar" />
</div>
</div>
@ -74,6 +83,7 @@ import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import navbar from "@/fmg-components/nav-bar/nav-bar";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import vehicleBanner from "@/fmg-components/vehicle-banner/vehicle-banner";
import alert from "@/ux-components/alert/alert";
import vehicleQuestion from "@/layouts/vehicle/vehicle-question/vehicle-question";
// Supporting files
@ -111,6 +121,7 @@ export default {
makeOptions: [],
modelOptions: [],
styleOptions: [],
displayNoServiceAlert: false,
};
},
@ -225,6 +236,7 @@ export default {
watch: {
async selectedYear(year) {
this.resetAlert();
this.makeOptions = [];
this.selectedMake = null;
this.selectedModel = null;
@ -244,6 +256,7 @@ export default {
}
},
async selectedMake(make) {
this.resetAlert();
this.modelOptions = [];
this.selectedModel = null;
this.selectedStyle = null;
@ -262,6 +275,7 @@ export default {
}
},
async selectedModel(model) {
this.resetAlert();
this.styleOptions = [];
this.selectedStyle = null;
this.carId = null;
@ -287,6 +301,7 @@ export default {
}
},
async selectedStyle(style) {
this.resetAlert();
if (style) {
this.getVehicleDetails();
} else {
@ -345,8 +360,11 @@ export default {
this.imageUrl = result?.data.imageUrl;
this.imageVifNumber = result?.data.imageVifNumber;
this.imageVifColor = result?.data.imageVifColor;
this.displayNoServiceAlert = !result?.data.canSafeliteService;
},
resetAlert() {
this.displayNoServiceAlert = false;
},
async forwardButtonAction() {
await this.dispatchStoreAction(storeActions.RESET_SUBMITTED_ORDER);
this.dispatchStoreAction(
@ -441,6 +459,7 @@ export default {
funnelSubHeader,
vehicleBanner,
Form,
alert,
vehicleQuestion,
},
};

View file

@ -171,10 +171,15 @@ router.beforeEach(async (to, from, next) => {
}
const toQueryPage = to.query?.fmgPage;
const notToPIAReturn = toQueryPage != "payment-pia-return";
const notToPIAReturn = toQueryPage != fmgPageValues.PAYMENT_PIA_RETURN;
const isInIframe = window !== window.top;
if (isInIframe && notToPIAReturn) {
// fromPaymentToConfirmation workaround for navigating from an iframe but
// isInIframe evaluates to false for some reason when navigating from payment to confirmation
const fromPaymentToConfirmation =
fromQueryPage === fmgPageValues.PAYMENT && toQueryPage === fmgPageValues.CONFIRMATION;
if ((isInIframe && notToPIAReturn) || fromPaymentToConfirmation) {
const newUrl = `${window.top.location.origin}${to.href}`;
window.top.location.href = newUrl;
// Refresh page if navigating to self to prevent locking.

View file

@ -15,7 +15,6 @@ const fmgPageValues = {
HERITAGE: "heritage",
SCHEDULE: "schedule",
CUSTOMER_DETAILS: "customer-details",
REVIEW: "review",
PAYMENT_METHOD: "payment-method",
PAYMENT: "payment",
PAYMENT_PIA_RETURN: "payment-pia-return",

View file

@ -44,17 +44,10 @@ const navigationScenarios = {
// Schedule
CLICKED_CHANGE_LOCATION: "CLICKED_CHANGE_LOCATION",
// Review
CLICKED_VEHICLE_EDIT: "CLICKED_VEHICLE_EDIT",
CLICKED_DAMAGE_EDIT: "CLICKED_DAMAGE_EDIT",
CLICKED_SERVICE_PACKAGE_EDIT: "CLICKED_SERVICE_PACKAGE_EDIT",
CLICKED_SERVICE_LOCATION_EDIT: "CLICKED_SERVICE_LOCATION_EDIT",
CLICKED_SCHEDULE_EDIT: "CLICKED_SCHEDULE_EDIT",
CLICKED_CUSTOMER_EDIT: "CLICKED_CUSTOMER_EDIT",
// Payment
CLICKED_PAY_NOW: "CLICKED_PAY_NOW",
PIA_ERROR: "PIA_ERROR",
PIA_CC_ERROR: "PIA_CC_ERROR",
PIA_SUCCESS: "PIA_SUCCESS",
};

View file

@ -428,43 +428,6 @@ const routingTable = function (store) {
scenario: navigationScenarios.CLICKED_BACK,
destinationFmgPageValue: fmgPageValues.SCHEDULE,
},
{
scenario: navigationScenarios.CLICKED_FORWARD,
destinationFmgPageValue: fmgPageValues.REVIEW,
},
],
},
{
fmgPageValue: fmgPageValues.REVIEW,
maps: [
{
scenario: navigationScenarios.CLICKED_VEHICLE_EDIT,
destinationFmgPageValue: fmgPageValues.VEHICLE,
},
{
scenario: navigationScenarios.CLICKED_DAMAGE_EDIT,
destinationFmgPageValue: fmgPageValues.VEHICLE_DAMAGE,
},
{
scenario: navigationScenarios.CLICKED_SERVICE_PACKAGE_EDIT,
destinationFmgPageValue: fmgPageValues.QUOTE,
},
{
scenario: navigationScenarios.CLICKED_BACK,
destinationFmgPageValue: fmgPageValues.CUSTOMER_DETAILS,
},
{
scenario: navigationScenarios.CLICKED_SERVICE_LOCATION_EDIT,
destinationFmgPageValue: fmgPageValues.SERVICE_LOCATION,
},
{
scenario: navigationScenarios.CLICKED_SCHEDULE_EDIT,
destinationFmgPageValue: fmgPageValues.SCHEDULE,
},
{
scenario: navigationScenarios.CLICKED_CUSTOMER_EDIT,
destinationFmgPageValue: fmgPageValues.CUSTOMER_DETAILS,
},
{
scenario: navigationScenarios.CLICKED_FORWARD,
destinationFmgPageValue: fmgPageValues.PAYMENT_METHOD,
@ -476,7 +439,7 @@ const routingTable = function (store) {
maps: [
{
scenario: navigationScenarios.CLICKED_BACK,
destinationFmgPageValue: fmgPageValues.REVIEW,
destinationFmgPageValue: fmgPageValues.CUSTOMER_DETAILS,
},
{
scenario: navigationScenarios.CLICKED_FORWARD,
@ -508,6 +471,10 @@ const routingTable = function (store) {
scenario: navigationScenarios.PIA_ERROR,
destinationFmgPageValue: fmgPageValues.PAYMENT_METHOD,
},
{
scenario: navigationScenarios.PIA_CC_ERROR,
destinationFmgPageValue: fmgPageValues.PAYMENT,
},
{
scenario: navigationScenarios.PIA_SUCCESS,
destinationFmgPageValue: fmgPageValues.CONFIRMATION,

View file

@ -28,6 +28,7 @@ import {
removeCurrentlyActivePromoCodesFromInactivePromos,
} from "@/helpers/promotions-helper";
import { getDateDifferenceInDays } from "@/helpers/date-helper";
import sharedMutations from "./vuex-shared-mutations/vuexSharedMutations";
// Export State
const getDefaultState = () => {
return {
@ -910,9 +911,10 @@ export const actions = {
},
validateZip(context, { payload: { zip }, pageNameToLog }) {
const damageType = context.getters.damage.isRepair ? "Repair" : "Replace";
return globalMethods.callHttpClient({
methods: endpoints.ValidateZip.method,
endpoint: `${endpoints.ValidateZip.url}/${zip}`,
endpoint: `${endpoints.ValidateZip.url}/${zip}/${damageType}`,
logApiCall: true,
pageNameToLog: pageNameToLog,
});
@ -2523,7 +2525,12 @@ export const actions = {
};
export default createStore({
plugins: [createPersistedState()],
plugins: [
createPersistedState(),
sharedMutations({
predicate: [...Object.values(storeMutations)],
}),
],
// IMPORTANT: Be VERY careful when modifying these fields for at least a few reasons:
// * The CMS can reference the fields by name
// * Return users may have a previous "version" of the model, and we don't want

View file

@ -571,6 +571,11 @@ describe("Actions", () => {
it("validateZip action", async () => {
// Arrange
const context = state;
context.getters = {
damage: {
isRepair: "false",
},
};
// Act
globalMethods.callHttpClient.mockImplementation(() => {

View file

@ -0,0 +1,31 @@
const DEFAULT_CHANNEL = "vuex-shared-mutations";
const globalObj =
typeof window !== "undefined" ? window : /* istanbul ignore next: node env */ global;
export default class BroadcastChannelStrategy {
static available(BroadcastChannelImpl = globalObj.BroadcastChannel) {
return !(typeof BroadcastChannelImpl !== "function");
}
constructor(options = {}) {
const BroadcastChannelImpl = options.BroadcastChannel || globalObj.BroadcastChannel;
const key = options.key || DEFAULT_CHANNEL;
if (!this.constructor.available(BroadcastChannelImpl)) {
throw new Error("Broadcast strategy not available");
}
this.channel = new BroadcastChannelImpl(key);
}
addEventListener(fn) {
this.channel.addEventListener("message", (e) => {
fn(e.data);
});
}
share(message) {
return this.channel.postMessage(message);
}
}

View file

@ -0,0 +1,82 @@
import BroadcastChannelStrategy from "./broadcastChannel";
describe("BroadcastChannelStrategy", () => {
const mockChannel = {
addEventListener: jest.fn(),
postMessage: jest.fn(),
};
const mockBroadcastChannel = jest.fn(() => mockChannel);
beforeEach(() => {
jest.clearAllMocks();
});
describe("available", () => {
it("should return true if BroadcastChannelImpl is a function", () => {
expect(BroadcastChannelStrategy.available(mockBroadcastChannel)).toBe(true);
});
it("should return false if BroadcastChannelImpl is not a function", () => {
expect(BroadcastChannelStrategy.available(null)).toBe(false);
});
});
describe("constructor", () => {
it("should create a new BroadcastChannel with the default channel name if no key is provided", () => {
const strategy = new BroadcastChannelStrategy({
BroadcastChannel: mockBroadcastChannel,
});
expect(mockBroadcastChannel).toHaveBeenCalledWith("vuex-shared-mutations");
expect(strategy.channel).toBe(mockChannel);
});
it("should create a new BroadcastChannel with the provided key", () => {
const strategy = new BroadcastChannelStrategy({
BroadcastChannel: mockBroadcastChannel,
key: "test-channel",
});
expect(mockBroadcastChannel).toHaveBeenCalledWith("test-channel");
expect(strategy.channel).toBe(mockChannel);
});
it("should throw an error if Broadcast strategy is not available", () => {
expect(() => new BroadcastChannelStrategy({ BroadcastChannel: null })).toThrow(
"Broadcast strategy not available"
);
});
});
describe("addEventListener", () => {
it("should add a message event listener to the channel", () => {
const strategy = new BroadcastChannelStrategy({
BroadcastChannel: mockBroadcastChannel,
});
const mockFn = jest.fn();
strategy.addEventListener(mockFn);
expect(mockChannel.addEventListener).toHaveBeenCalledWith(
"message",
expect.any(Function)
);
// const mockEvent = { data: "test-message" };
// console.log(mockChannel.addEventListener.mock.calls);
// mockChannel.addEventListener.mock.calls[0][1];
// expect(mockFn).toHaveBeenCalledWith(mockEvent.data);
});
});
describe("share", () => {
it("should post a message to the channel", () => {
const strategy = new BroadcastChannelStrategy({
BroadcastChannel: mockBroadcastChannel,
});
const mockMessage = { test: "message" };
expect(strategy.share(mockMessage)).toBeUndefined();
expect(mockChannel.postMessage).toHaveBeenCalledWith(mockMessage);
});
});
});

View file

@ -0,0 +1,17 @@
import BroadcastChannelStrategy from "./broadcastChannel";
import LocalStorageStrategy from "./localStorage";
export default function createDefaultStrategy() {
/* istanbul ignore next: browser-dependent code */
if (LocalStorageStrategy.available()) {
return new LocalStorageStrategy();
}
/* istanbul ignore next: browser-dependent code */
if (BroadcastChannelStrategy.available()) {
return new BroadcastChannelStrategy();
}
/* istanbul ignore next: browser-dependent code */
throw new Error("No strategies available");
}

View file

@ -0,0 +1,101 @@
const DEFAULT_KEY = "vuex-shared-mutations";
const globalObj =
typeof window !== "undefined" ? window : /* istanbul ignore next: node env */ global;
const MAX_MESSAGE_LENGTH = 4 * 1024;
let messageCounter = 1;
function splitMessage(message) {
const partsCount = Math.ceil(message.length / MAX_MESSAGE_LENGTH);
return Array.from({ length: partsCount }).map((_, idx) =>
message.substr(idx * MAX_MESSAGE_LENGTH, MAX_MESSAGE_LENGTH)
);
}
export default class LocalStorageStrategy {
static available(
{ window: windowImpl, localStorage: localStorageImpl } = {
window: globalObj.window,
localStorage: globalObj.localStorage,
}
) {
if (!windowImpl || !localStorageImpl) {
return false;
}
try {
localStorageImpl.setItem("vuex-shared-mutations-test-key", Date.now());
localStorageImpl.removeItem("vuex-shared-mutations-test-key");
return true;
} catch (e) {
return false;
}
}
constructor(options = {}) {
const windowImpl = options.window || globalObj.window;
const localStorageImpl = options.localStorage || globalObj.localStorage;
if (
!this.constructor.available({
window: windowImpl,
localStorage: localStorageImpl,
})
) {
throw new Error("Strategy unavailable");
}
this.uniqueId = `${Date.now()}-${Math.random()}`;
this.messageBuffer = [];
this.window = windowImpl;
this.storage = localStorageImpl;
this.options = {
key: DEFAULT_KEY,
...options,
};
}
// eslint-disable-next-line class-methods-use-this
addEventListener(fn) {
return this.window.addEventListener("storage", (event) => {
if (!event.newValue) {
return false;
}
if (event.key.indexOf("##") === -1 || event.key.split("##")[0] !== this.options.key) {
return false;
}
const message = this.window.JSON.parse(event.newValue);
/* istanbul ignore next: IE does not follow storage event spec */
if (message.author === this.uniqueId) {
return false;
}
this.messageBuffer.push(message.messagePart);
if (this.messageBuffer.length === message.total) {
const mutation = this.window.JSON.parse(this.messageBuffer.join(""));
this.messageBuffer = [];
fn(mutation);
}
return true;
});
}
share(message) {
const rawMessage = this.window.JSON.stringify(message);
const messageParts = splitMessage(rawMessage);
messageParts.forEach((m, idx) => {
messageCounter += 1;
const key = `${this.options.key}##${idx}`;
this.storage.setItem(
key,
JSON.stringify({
author: this.uniqueId,
part: idx,
total: messageParts.length,
messagePart: m,
messageCounter,
})
);
this.storage.removeItem(key);
});
}
}

View file

@ -0,0 +1,22 @@
import LocalStorageStrategy from "./localStorage";
describe("LocalStorageStrategy", () => {
describe("available", () => {
it("should return true if localStorage is available", () => {
const localStorageMock = {
setItem: jest.fn(),
removeItem: jest.fn(),
};
expect(
LocalStorageStrategy.available({ window: {}, localStorage: localStorageMock })
).toBe(true);
});
it("should return false if localStorage is not available", () => {
const windowMock = {};
expect(LocalStorageStrategy.available({ window: windowMock })).toBe(false);
});
it("should return false if window is not available", () => {
expect(LocalStorageStrategy.available({ window: undefined })).toBe(false);
});
});
});

View file

@ -0,0 +1,51 @@
//Share vuex mutations between tabs/windows
//Referenced by https://github.com/xanf/vuex-shared-mutations/
import createDefaultStrategy from "./strategies/defaultStrategy";
export { default as BroadcastChannelStrategy } from "./strategies/broadcastChannel";
export { default as LocalStorageStratery } from "./strategies/localStorage";
export default ({ predicate, strategy, ...rest } = {}) => {
/* istanbul ignore next: deprecation warning */
if ("storageKey" in rest || "sharingKey" in rest) {
window.console.warn(
"Configuration directly on plugin was removed, configure specific strategies if needed"
);
}
if (!Array.isArray(predicate) && typeof predicate !== "function") {
throw new Error(
"Either array of accepted mutations or predicate function must be supplied"
);
}
const predicateFn =
typeof predicate === "function" ? predicate : ({ type }) => predicate.indexOf(type) !== -1;
let sharingInProgress = false;
const selectedStrategy = strategy || createDefaultStrategy();
return (store) => {
store.subscribe(async (mutation, state) => {
if (sharingInProgress) {
return Promise.resolve(false);
}
const shouldShare = await Promise.resolve(predicateFn(mutation, state));
if (!shouldShare) {
return;
}
selectedStrategy.share(mutation);
});
selectedStrategy.addEventListener((mutation) => {
try {
sharingInProgress = true;
store.commit(mutation.type, mutation.payload);
} finally {
sharingInProgress = false;
}
return "done";
});
};
};

View file

@ -0,0 +1,88 @@
import createMutationsSharer from "./vuexSharedMutations";
describe("Vuex shared mutations", () => {
it("should throw an error if predicate function is not supplied", () => {
expect(() => {
createMutationsSharer();
}).toThrowError(Error);
});
it("should accept array as predicate", () => {
expect(() => {
createMutationsSharer({
predicate: ["m-1"],
});
}).not.toThrowError(Error);
});
it("should accept function as predicate", () => {
expect(() => {
createMutationsSharer({
predicate: jest.fn(),
});
}).not.toThrowError(Error);
});
it("should share relevant mutation", () => {
let capturedHandler;
const fakeStrategy = {
share: jest.fn(),
addEventListener: jest.fn(),
};
const fakeStore = {
subscribe(fn) {
capturedHandler = fn;
},
};
createMutationsSharer({
predicate: ["m-1"],
strategy: fakeStrategy,
})(fakeStore);
return capturedHandler({ type: "m-1", payload: "lol" }).then(() => {
expect(fakeStrategy.share).toHaveBeenCalled();
});
});
it("should not share irrelevant mutation", () => {
let capturedHandler;
const fakeStrategy = {
share: jest.fn(),
addEventListener: jest.fn(),
};
const fakeStore = {
subscribe(fn) {
capturedHandler = fn;
},
};
createMutationsSharer({
predicate: ["m-1"],
strategy: fakeStrategy,
})(fakeStore);
return capturedHandler({ type: "m-2", payload: "lol" }).then(() => {
expect(fakeStrategy.share).not.toHaveBeenCalled();
});
});
it("should respect predicate function when sharing mutation", () => {
let capturedHandler;
const fakeStrategy = {
share: jest.fn(),
addEventListener: jest.fn(),
};
const fakeStore = {
subscribe(fn) {
capturedHandler = fn;
},
};
createMutationsSharer({
predicate: ({ type }) => ["m-1"].indexOf(type) !== -1,
strategy: fakeStrategy,
})(fakeStore);
return capturedHandler({ type: "m-1", payload: "lol" }).then(() => {
expect(fakeStrategy.share).toHaveBeenCalled();
});
});
});