Merge branch 'develop' into feature/CSR-886

This commit is contained in:
Adam Caouette 2023-03-15 09:54:17 -04:00
commit 3693636924
121 changed files with 4408 additions and 937 deletions

View file

@ -101,11 +101,16 @@ stages:
indexDeployVariables:
__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__: $(__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__)
__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__: $(__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__)
cfDistributionId: $(vueCfDistributionId)
- template: templates/digital/invalidate-cloudfront-cache.yml@AzureDevOps
parameters:
awsCliContainer: awscli
distributionId: $(apiCfDistributionId)
paths: /*
awsProfile: $(devDeploymentProfile)
- template: templates/digital/invalidate-cloudfront-cache.yml@AzureDevOps
parameters:
awsCliContainer: awscli
distributionId: $(cfDistributionId)
paths: /fmg/*
awsProfile: $(devDeploymentProfile)
@ -153,11 +158,16 @@ stages:
indexDeployVariables:
__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__: $(__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__)
__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__: $(__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__)
cfDistributionId: $(vueCfDistributionId)
- template: templates/digital/invalidate-cloudfront-cache.yml@AzureDevOps
parameters:
awsCliContainer: awscli
distributionId: $(apiCfDistributionId)
paths: /*
awsProfile: $(sysDeploymentProfile)
- template: templates/digital/invalidate-cloudfront-cache.yml@AzureDevOps
parameters:
awsCliContainer: awscli
distributionId: $(cfDistributionId)
paths: /fmg/*
awsProfile: $(sysDeploymentProfile)
@ -205,11 +215,16 @@ stages:
indexDeployVariables:
__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__: $(__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__)
__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__: $(__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__)
cfDistributionId: $(vueCfDistributionId)
- template: templates/digital/invalidate-cloudfront-cache.yml@AzureDevOps
parameters:
awsCliContainer: awscli
distributionId: $(apiCfDistributionId)
paths: /*
awsProfile: $(qaDeploymentProfile)
- template: templates/digital/invalidate-cloudfront-cache.yml@AzureDevOps
parameters:
awsCliContainer: awscli
distributionId: $(cfDistributionId)
paths: /fmg/*
awsProfile: $(qaDeploymentProfile)
@ -257,11 +272,16 @@ stages:
indexDeployVariables:
__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__: $(__VUE_APP_GOOGLE_TAG_MANAGER_SCRIPT_BODY__)
__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__: $(__VUE_APP_GOOGLE_TAG_MANAGER_NOSCRIPT_FRAME_SRC__)
cfDistributionId: $(apiCfDistributionId)
- template: templates/digital/invalidate-cloudfront-cache.yml@AzureDevOps
parameters:
awsCliContainer: awscli
distributionId: $(apiCfDistributionId)
paths: /*
awsProfile: $(prodDeploymentProfile)
- template: templates/digital/invalidate-cloudfront-cache.yml@AzureDevOps
parameters:
awsCliContainer: awscli
distributionId: $(cfDistributionId)
paths: /fmg/*
awsProfile: $(prodDeploymentProfile)
- template: templates/digital/auto-tag.yml@AzureDevOps

581
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -54,6 +54,10 @@ const endpoints = {
url: "/vehicle/api/v1/vehicle/lookup-vin-by-address",
method: "POST",
},
IsVinByAddressPermissible: {
url: "/vehicle/api/v1/vehicle/is-vin-by-address-permissible",
method: "GET",
},
GetPartsOrQuestions: {
url: "/parts/api/v1/parts/parts-or-questions",
method: "POST",
@ -70,6 +74,10 @@ const endpoints = {
url: "/parts/api/v1/parts/rain-defense",
method: "GET",
},
GetMobileFeePart: {
url: "/parts/api/v1/parts/mobile-fee",
method: "GET",
},
GetSupportingItems: {
url: "/parts/api/v1/parts/supporting-items",
method: "POST",

View file

@ -1,6 +1,7 @@
const queryStrings = {
FMG_PAGE: "fmgPage",
START_TYPE: "start_type",
ZIP_CODE: "zipcode",
};
export { queryStrings };

View file

@ -13,6 +13,7 @@ const storeActions = {
GET_DAMAGE_OPTIONS: "getDamageOptions",
GET_EVOX_IMAGE: "getEvoxImage",
IS_VIN_OPTIONAL_VEHICLE: "isVinOptionalVehicle",
IS_VIN_BY_ADDRESS_PERMISSIBLE: "isVinByAddressPermissible",
// Lookup Actions
LOOKUP_VEHICLE_BY_YMMS: "lookupVehicleByYmms",
@ -27,11 +28,12 @@ const storeActions = {
GET_CAPABILITY_QUESTIONS: "getCapabilityQuestions",
GET_PART_FROM_CAPABILITY_QUESTION_ANSWER: "getPartFromCapabilityQuestionAnswer",
GET_MOLDING_QUESTIONS: "getMoldingQuestions",
GET_MOBILE_FEE_PART: "getMobileFeePart",
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",
@ -66,7 +68,7 @@ const storeActions = {
SAVE_MOLDING_QUESTION_ANSWERS: "saveMoldingQuestionAnswers",
SAVE_CAPABILITY_QUESTION_ANSWERS: "saveCapabilityQuestionAnswers",
SAVE_PAYMENT_TYPE: "savePaymentType",
SAVE_ACCOUNT_NUMBER: "saveAccountNumber",
SAVE_PARENT_ACCOUNT_NUMBER: "saveParentAccountNumber",
SAVE_SUPPORTING_ITEMS: "saveSupportingItems",
SAVE_VAPS: "saveVaps",
};

View file

@ -32,6 +32,7 @@ const tintMap = {
{ name: "gray tint", src: "Glass-NoShade-GrayTint.svg" },
{ name: "green tint", src: "Glass-NoShade-GreenTint.svg" },
{ name: "gray tint privacy", src: "Glass-NoShade-GrayTint.svg" },
{ name: "blue tint", src: "Glass-NoShade-BlueTint.svg" },
// No shade or tint
{ name: "clear", src: "Glass-NoShade-NoTint.svg" },

View file

@ -33,7 +33,7 @@ import {
handleButtonComponentFocus,
handleInputComponentBlur,
} from "@/helpers/button-question-focus-helper";
import { inputButtonProps } from "@/common-components/base-input-button/button-functionality-props";
import { inputButtonProps } from "@/digital-components/base-input-button/button-functionality-props";
export default {
name: "base-input-button",

View file

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

View file

@ -1,5 +1,5 @@
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";
describe("buttonQuestion.vue", () => {

View file

@ -1,10 +1,14 @@
<!-- Documented in confluence https://safelite.atlassian.net/wiki/spaces/DC/pages/76644418/Button+Question+Component -->
<!-- Documented in confluence
https://safelite.atlassian.net/wiki/spaces/DC/pages/76644418/Button+Question+Component -->
<template>
<div
:class="
isOverflowScrollable ? 'button-question button-question-overflow' : 'button-question'
">
<div v-if="questionText && answers && answers.length > 0" class="question-text d-flex">
<div
v-if="questionText && answers && answers.length > 0"
class="question-text d-flex"
:class="{ 'small-question-text': isSmallQuestionText }">
<span class="fw-bold w-100">{{ questionText }}</span>
</div>
@ -52,6 +56,7 @@
:lastValuePushedToGa="lastValuePushedToGa"
:setLastValuePushedToGa="setLastValuePushedToGa"
:suppressError="suppressError"
@buttonEvent="handleButtonEvent"
v-model="selectedValues" />
<!-- For nested questions -->
<transition name="fade" mode="out-in">
@ -68,7 +73,10 @@
</fieldset>
</div>
<div class="row form-test-error mt-1">
<error-message :name="formatString(groupName)" v-if="!suppressError"></error-message>
<error-message
class="small"
:name="formatString(groupName)"
v-if="!suppressError"></error-message>
</div>
</div>
</template>
@ -119,6 +127,7 @@ export default {
useTextForValue: Boolean,
valueToLogType: String,
additionalButtonStyling: String,
isSmallQuestionText: Boolean,
},
beforeMount() {
if (this.buttonTypeObject) {
@ -132,13 +141,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 +216,15 @@ 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);
},
},
components: {
listButton,
@ -251,19 +266,18 @@ export default {
text-align: center;
}
}
.vehicle-parts {
.question-text {
.small-question-text {
&.question-text {
span {
font-size: 0.875rem;
text-align: left;
margin: 0 0 0.5rem 0;
margin: 1rem 0 0.5rem 0;
}
}
.question-text {
&.question-text {
margin: 0;
}
fieldset {
& fieldset {
.ui-radio {
margin: 0;
}

View file

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

View file

@ -1,10 +1,12 @@
<template>
<div class="dropdown-question" :class="errorMessage || hasError ? 'has-error' : ''">
<div
class="dropdown-question"
:class="(errors && errors.length) || hasError ? 'has-error' : ''">
<label
:for="inputId"
:aria-label="questionText"
class="form-label"
v-html="labelText"></label>
v-html="questionText"></label>
<select
v-model="selectedOption"
class="form-select"
@ -13,7 +15,9 @@
: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,13 @@ export default {
validationRules: String,
cmsWidgetName: String,
hasError: Boolean,
placeHolderText: String,
},
setup(props) {
const inputId = !props.customInputId
? `dropdown-${crypto.randomUUID()}`
: props.customInputId;
const propsClone = Object.assign({}, props);
const modelValue = propsClone.modelValue;
let initialValue;
@ -62,55 +71,36 @@ export default {
initialValue: initialValue,
};
const { errorMessage, handleBlur, handleChange, meta } = useField(
props.inputId,
const { errorMessage, handleBlur, handleChange, meta, errors } = useField(
inputId,
props.validationRules,
fieldOptions
);
return {
inputId,
errorMessage,
handleBlur,
handleChange,
meta,
errors,
};
},
mounted() {
this.$emit("dropdownQuestionEvent.inputIdAssigned", this.inputId);
},
computed: {
questionText() {
return this.getCmsContent(this.cmsWidgetName, "QuestionText");
},
selectedOption: {
get: function () {
return this.modelValue;
return !this.modelValue ? "" : this.modelValue;
},
set: function (newValue) {
this.$emit("update:modelValue", newValue);
},
},
labelText: {
get: function () {
const noBreakChar = "&NoBreak;";
var questionText = "";
if (this.disableAutoFill) {
var words = this.questionText.toString().split(/[ ]+/);
words.forEach(function (word) {
const position = 1;
word = [
word.toString().slice(0, position),
noBreakChar,
word.toString().slice(position),
].join("");
questionText += `${word} `;
});
questionText = questionText.trimEnd();
} else {
questionText = this.questionText.toString();
}
return questionText;
},
},
},
watch: {
selectedOption(newValue) {

View file

@ -0,0 +1,199 @@
jest.mock("vee-validate", () => ({
useForm: jest.fn(),
useIsFormTouched: jest.fn(),
useIsFormDirty: jest.fn(),
useIsFormValid: jest.fn(),
}));
const mockValidate = (returnValue) => jest.fn(async () => Promise.resolve({ valid: returnValue }));
import { shallowMount } from "@vue/test-utils";
import modal from "./modal";
import crypto from "crypto";
import { useForm, useIsFormDirty, useIsFormTouched, useIsFormValid } 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 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 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
useForm.mockReturnValue({
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: "buttonMain" });
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
useForm.mockReturnValue({
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: "buttonMain" });
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
useForm.mockReturnValue({
validate: mockValidate(false),
});
useIsFormTouched.mockReturnValue(false);
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
useForm.mockReturnValue({
validate: mockValidate(false),
});
useIsFormTouched.mockReturnValue(true);
useIsFormDirty.mockReturnValue(true);
useIsFormValid.mockReturnValue(false);
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
useForm.mockReturnValue({
validate: mockValidate(false),
});
useIsFormDirty.mockReturnValue(true);
useIsFormValid.mockReturnValue(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
useForm.mockReturnValue({
validate: mockValidate(false),
});
useIsFormDirty.mockReturnValue(true);
useIsFormValid.mockReturnValue(true);
const hideMock = jest.spyOn(Modal.prototype, "hide");
const resetButtonStyle = jest.fn();
const wrapper = shallowMount(modal, {
props: {
footerButtonText: footerButtonText,
headerText: headerText,
},
attachTo: document.body,
});
wrapper.vm.resetButtonStyle = resetButtonStyle;
// Act
wrapper.vm.openModal();
wrapper.vm.closeModal();
// Assert
expect(hideMock).toHaveBeenCalled();
});
});

View file

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

View file

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

View file

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

View file

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

View file

@ -9,6 +9,7 @@
export default {
name: "textBlock",
props: {
customText: String, // used to allow the insert of token values into textblock
justifyText: String, // left, right, center
typeStyle: String, // h1-h6, body, small, label, caption (see Figma or Confluence documentation)
fontWeight: String, // bold=500, default is 400
@ -16,6 +17,9 @@ export default {
},
computed: {
TextBlockCopy() {
if (this.customText) {
return this.customText;
}
return this.getCmsContent(this.cmsWidgetName, "Text");
},
},

View file

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

View file

@ -1,11 +1,12 @@
<template>
<div class="textbox-question" :class="(errors && errors.length) || hasError ? 'has-error' : ''">
<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>
v-html="questionText"></label>
<div class="input-wrapper" :class="[includeSearchIcon ? 'has-search-icon' : '']">
<input
class="form-control"
@ -32,15 +33,14 @@
@focus="$emit('focus', $event.target.value)" />
<button v-if="includeSearchIcon" type="submit" aria-label="Search button" />
</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">{{ errorMessage }}</span>
</div>
</div>
</template>
<script>
import { useField, validate } from "vee-validate";
import { storeActions } from "@/constants/store-actions";
export default {
name: "textbox-question",
@ -53,8 +53,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
@ -72,6 +76,8 @@ export default {
includeSearchIcon: Boolean,
},
setup(props) {
const inputId = !props.customInputId ? `input-${crypto.randomUUID()}` : props.customInputId;
const propsClone = Object.assign({}, props);
const modelValue = propsClone.modelValue;
let initialValue;
@ -92,12 +98,13 @@ export default {
};
const { errorMessage, handleBlur, handleChange, meta, validate, errors } = useField(
props.inputId,
inputId,
props.validationRules,
fieldOptions
);
return {
inputId,
errorMessage,
handleBlur,
handleChange,
@ -118,30 +125,9 @@ export default {
this.$emit("update:modelValue", newValue);
},
},
labelText: {
get: function () {
const noBreakChar = "&NoBreak;";
var questionText = "";
if (this.disableAutoFill) {
var words = this.questionText.toString().split(/[ ]+/);
words.forEach(function (word) {
const position = 1;
word = [
word.toString().slice(0, position),
noBreakChar,
word.toString().slice(position),
].join("");
questionText += `${word} `;
});
questionText = questionText.trimEnd();
} else {
questionText = this.questionText.toString();
}
return questionText;
},
},
},
mounted() {
this.$emit("textboxQuestionEvent.inputIdAssigned", this.inputId);
},
watch: {
async value(newValue) {

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
test.todo("some test to be written in the future");

View file

@ -66,7 +66,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">

View file

@ -2,7 +2,7 @@
<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>
@ -25,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,
@ -46,9 +50,6 @@ export default {
backButtonAccessibleText() {
return this.getCmsContent(this.cmsWidgetName, "BackButtonAccessibleText");
},
headerColor() {
return this.subText ? "dark-header" : "light-header";
},
},
methods: {
clickEvent() {

View file

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

View file

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

View file

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

View file

@ -28,7 +28,11 @@ export async function getPageToRouteExistingOrderTo(toRoute = {}, existingHerita
// If navigating to a specific page, and that page is not part of the vin pages.
// Return that page, so that it can navigate like normal.
if (toRoute.query[queryStrings.FMG_PAGE] !== undefined && !isVinRelatedPage(toRoute)) {
if (
toRoute.query[queryStrings.FMG_PAGE] !== undefined &&
toRoute.query[queryStrings.FMG_PAGE] !== fmgPageValues.QUOTE &&
!isVinRelatedPage(toRoute)
) {
return overrideYmmsDirectionIfNeeded(toRoute);
}
@ -107,6 +111,7 @@ async function getLatestPageForRedirection() {
const capabilityQuestionsComponent = await getLazyLoadedComponent(
fmgPageValues.CAPABILITY_QUESTIONS
);
const quoteComponent = await getLazyLoadedComponent(fmgPageValues.QUOTE);
const skipVin = await skipVinLookup();
@ -121,7 +126,9 @@ async function getLatestPageForRedirection() {
} else if (!estimateComponent.methods.arePagePrerequisitesValid()) {
return fmgPageValues.VEHICLE_DAMAGE;
} else {
if (capabilityQuestionsComponent.methods.arePagePrerequisitesValid()) {
if (quoteComponent.methods.arePagePrerequisitesValid()) {
return fmgPageValues.QUOTE;
} else if (capabilityQuestionsComponent.methods.arePagePrerequisitesValid()) {
return fmgPageValues.CAPABILITY_QUESTIONS;
} else if (moldingQuestionsComponent.methods.arePagePrerequisitesValid()) {
return fmgPageValues.MOLDING_QUESTIONS;

View file

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

View file

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

View file

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

View file

@ -105,7 +105,7 @@ export function getMockOrderInfo(
mockReferralNumber,
mockCorrelationId,
mockReferralDate,
accountNumber = "0",
parentAccountNumber = "0",
savedSessionId,
crmCustomerId
) {
@ -113,7 +113,7 @@ export function getMockOrderInfo(
referralNumber: mockReferralNumber,
referralCorrelationId: mockCorrelationId,
referralDate: mockReferralDate,
accountNumber: accountNumber,
parentAccountNumber: parentAccountNumber,
savedSessionId: savedSessionId,
crmCustomerId: crmCustomerId,
};

View file

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

View file

@ -7,6 +7,7 @@ import { mount, shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import { storeMutations } from "@/constants/store-mutations";
import store from "@/store";
import { createImportSpecifier } from "typescript";
let autocompleteElement;
describe("address-questions.vue", () => {
@ -18,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({});
@ -58,7 +59,81 @@ describe("address-questions.vue", () => {
expect(zipCode.exists()).toBe(true);
});
test("Should it set this.showAddressFields to true when the model is prepopulated", async () => {
test("Should render apartmentNumberOrBusinessName textbox-question when captureApartmentNumberOrBusinessName = true", async () => {
// Arrange
const { wrapper } = setupMocks({
props: {
modelValue: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
captureApartmentNumberOrBusinessName: true,
},
});
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(true);
expect(city.exists()).toBe(true);
expect(state.exists()).toBe(true);
expect(zipCode.exists()).toBe(true);
});
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 = {
@ -305,38 +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",
},
});
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;
@ -413,110 +456,110 @@ describe("address-questions.vue", () => {
describe("noMatch alert is cleared on address change", () => {
test("user sees noMatch warning and modifies street address => noMatch warning is removed", async () => {
// Arrange
const { wrapper } = setupMocks({});
const { wrapper } = setupMocks({
props: {
modelValue: {
streetAddress: "LS",
city: "",
state: "",
zipCode: "",
},
},
});
// Initial condition to display noMatch warning
await wrapper.setData({
matchFound: false,
});
// Act
wrapper.vm.addressModel.streetAddress = "LJS";
await wrapper.vm.$nextTick();
let noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
expect(noMatchAlert.exists()).toBeTruthy();
expect(noMatchAlert.isVisible()).toBeTruthy();
// // Act
wrapper.vm.$options.watch.addressModel.handler.call(wrapper.vm, {
streetAddress: "LS",
});
// Assert
wrapper.vm.$nextTick(function () {
expect(wrapper.vm.displayNoMatchWarning).toBeFalsy();
noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
expect(noMatchAlert.exists()).toBeFalsy();
});
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({});
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();
let noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
expect(noMatchAlert.exists()).toBeTruthy();
expect(noMatchAlert.isVisible()).toBeTruthy();
// // Act
wrapper.vm.$options.watch.addressModel.handler.call(wrapper.vm, {
city: "LS",
});
// Assert
wrapper.vm.$nextTick(function () {
expect(wrapper.vm.displayNoMatchWarning).toBeFalsy();
noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
expect(noMatchAlert.exists()).toBeFalsy();
});
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({});
const { wrapper } = setupMocks({
props: {
modelValue: {
streetAddress: "",
city: "",
state: "LS",
zipCode: "",
},
},
});
// Initial condition to display noMatch warning
await wrapper.setData({
matchFound: false,
});
// Act
wrapper.vm.addressModel.state = "LJS";
await wrapper.vm.$nextTick();
let noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
expect(noMatchAlert.exists()).toBeTruthy();
expect(noMatchAlert.isVisible()).toBeTruthy();
// // Act
wrapper.vm.$options.watch.addressModel.handler.call(wrapper.vm, {
state: "KO",
});
// Assert
wrapper.vm.$nextTick(function () {
expect(wrapper.vm.displayNoMatchWarning).toBeFalsy();
noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
expect(noMatchAlert.exists()).toBeFalsy();
});
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({});
const { wrapper } = setupMocks({
props: {
modelValue: {
streetAddress: "",
city: "",
state: "",
zipCode: "LS",
},
},
});
// Initial condition to display noMatch warning
await wrapper.setData({
matchFound: false,
});
// Act
wrapper.vm.addressModel.zipCode = "LJS";
await wrapper.vm.$nextTick();
let noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
expect(noMatchAlert.exists()).toBeTruthy();
expect(noMatchAlert.isVisible()).toBeTruthy();
// // Act
wrapper.vm.$options.watch.addressModel.handler.call(wrapper.vm, {
zipCode: "12345",
});
// Assert
wrapper.vm.$nextTick(function () {
expect(wrapper.vm.displayNoMatchWarning).toBeFalsy();
noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
expect(noMatchAlert.exists()).toBeFalsy();
});
let noMatchAlert = wrapper.findComponent({ ref: "alertNoMatchWarning" });
expect(noMatchAlert.exists()).toBeFalsy();
});
});
});
@ -576,6 +619,7 @@ function setupMocks({
const wrapper = isShallowMount
? shallowMount(addressQuestions, resultingMountOptions)
: mount(addressQuestions, resultingMountOptions);
document.querySelector = jest.fn().mockImplementation((query) => {
let result = null;
if (query == ".pac-container") result = document.createElement("div");

View file

@ -1,30 +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"
@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>
@ -33,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>
@ -71,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";
@ -94,12 +104,17 @@ export default {
type: Object,
default: () => ({
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
}),
},
validationRules: String,
captureApartmentNumberOrBusinessName: {
type: Boolean,
default: false,
},
},
data() {
return {
@ -110,10 +125,7 @@ export default {
alertCopyVerificationWarning: "",
alertHeadlineNoMatchWarning: "",
alertCopyNoMatchWarning: "",
matchingIndirectly: false,
matchFound: null, // null = no attempted match, true = match was found, false = match was not found
enterPressed: false,
isAddressWatchActive: false, // Only deep watch the address model when a match was not found
};
},
computed: {
@ -182,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 &&
@ -201,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
@ -235,27 +253,29 @@ export default {
});
addressField1.addEventListener("keydown", (e) => {
if (e.code === "Enter" || e.code === "NumpadEnter" || e.code === "Tab") {
if (e.code === "Tab") {
self.matchingIndirectly = true;
} else {
self.enterPressed = true;
}
const autocomplete = document.getElementById("autocomplete");
const event = new Event("place_changed");
addressField1.blur();
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 {
// Fill-in the address using first item in the list.
fillInAddressUsingFirstItem();
}
} else {
return;
}
});
addressField1.addEventListener("change", () => {
// NOTE: The "place_changed" event of the autocomplete fires after this and will use either the address the user had chosen
// using either the down / up arrows or the address the user was hovering over when they pressed "Enter."
// If a match has been previously found then do nothing
// OR
// If the user pressed "Enter" then do nothing
if (self.matchFound || self.enterPressed) {
// If a match has been previously attempted then do nothing
if (self.matchFound !== null) {
return;
}
@ -267,29 +287,32 @@ export default {
// 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.
const item = document.querySelector(".pac-container .pac-item");
if (item != null) {
self.matchingIndirectly = true;
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]);
}
}
);
} else {
// No addresses found for the input
self.matchFound = false;
}
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();
@ -297,7 +320,7 @@ export default {
if (place && place.address_components) {
self.matchFound = true;
self.addressModel.streetAddress = "";
self.$nextTick(function () {
self.showAddressFields = true;
@ -329,8 +352,6 @@ export default {
}
}
self.displayVerificationWarning = self.matchingIndirectly;
// after showing the address fields, disable the address autocomplete
window.google.maps.event.removeListener(autocompleteListener);
window.google.maps.event.clearInstanceListeners(autocomplete);
@ -340,8 +361,6 @@ export default {
pacContainer.remove();
}
});
} else {
self.displayVerificationWarning = true;
}
}
})
@ -357,6 +376,13 @@ export default {
watch: {
matchFound: {
handler(newValue) {
if (newValue === null) {
this.displayNoMatchWarning = false;
this.displayVerificationWarning = false;
this.unwatchAddress();
return;
}
if (!newValue) {
this.displayNoMatchWarning = true;
@ -366,21 +392,23 @@ export default {
this.showAddressFields = true;
this.displayVerificationWarning = false;
this.$nextTick(function () {
// Only deep watch the Address Model after a failed match
this.isAddressWatchActive = true;
});
// 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" }
);
}
},
},
addressModel: {
modelValue: {
handler() {
if (this.isAddressWatchActive) {
this.displayNoMatchWarning = false;
this.isAddressWatchActive = false;
}
this.setupAddressLookup();
},
deep: true,
},
},
components: {
@ -392,6 +420,10 @@ export default {
</script>
<style lang="scss">
.address-questions {
margin-top: 0.5rem;
}
#streetAddressField {
position: relative;

View file

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

View file

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

View file

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

View file

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

View file

@ -22,8 +22,8 @@
<script>
// Components
import datePicker from "@/common-components/date-picker/date-picker";
import funnelHeader from "@/common-components/funnel-header/funnel-header";
import datePicker from "@/digital-components/date-picker/date-picker";
import funnelHeader from "@/fmg-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";

View file

@ -258,6 +258,9 @@ function setupMocks({
navigateWithSaving: jest.fn(),
navigateWithoutSaving: jest.fn(),
},
route: {
query: {},
},
},
}) {
//Mock CMS Content

View file

@ -1,10 +1,10 @@
<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm" v-slot="{ meta }">
<div class="page-container-grouped-styles estimate">
<div class="page-container-grouped-styles">
<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";
@ -112,6 +110,8 @@ import { experimentSettings } from "@/constants/experiments";
import vinPagesMixin from "@/mixins/vin-pages-mixin";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import { saveSession } from "@/helpers/heritage-integration/order-helper.js";
import baseMixin from "@/mixins/base-mixin.js";
import { queryStrings } from "@/constants/query-strings";
// Define Validation Rules
defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED));
@ -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
)
);
@ -132,7 +132,7 @@ export default {
data() {
return {
selectedVinLookupMethod: null,
serviceZipCode: this.getZipFromStore(),
serviceZipCode: this.getZipFromStore() ?? this.$route.query.zipcode,
emailAddress: this.getEmailFromStore(),
displayInvalidZipAlert: false,
displayNonServiceableZipAlert: false,
@ -145,12 +145,31 @@ export default {
//Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
const hasZip = urlParams.has(queryStrings.ZIP_CODE);
const zip = urlParams.get(queryStrings.ZIP_CODE);
var vinByAddressPromise;
if (hasZip) {
vinByAddressPromise = baseMixin.methods.dispatchStoreAction(
storeActions.IS_VIN_BY_ADDRESS_PERMISSIBLE,
zip,
false
);
}
//Settle promises and get results
const promiseResultMap = [
{
resultKey: "cmsContent",
promise: cmsContentPromise,
},
zip != null &&
zip != undefined && {
resultKey: "vinByAddress",
promise: vinByAddressPromise,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
@ -172,6 +191,14 @@ export default {
}
}
if (zip && resultMap.vinByAddress === false) {
var indexToRemove = resultMap.cmsContent.VinLookupMethod.Answers.findIndex(
(answer) => answer.Name === "HomeAddress"
);
if (indexToRemove) {
resultMap.cmsContent.VinLookupMethod.Answers.splice(indexToRemove, 1);
}
}
vm.setCmsContent(resultMap.cmsContent);
});
},
@ -223,6 +250,25 @@ 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(
@ -300,12 +346,9 @@ export default {
</script>
<style lang="scss">
.estimate .vinLookupMethodHeading p {
.vinLookupMethodHeading p {
font-size: 1rem;
font-weight: 500;
color: $black;
}
.estimate .funnel-sub-header {
padding-bottom: 16px;
}
</style>

View file

@ -660,6 +660,9 @@ function setupMocks({
navigateWithoutSaving: jest.fn(),
navigateWithSaving: jest.fn(),
},
route: {
query: {},
},
store: {
getters: {
vehicle: {

View file

@ -91,13 +91,13 @@
<script>
// Components
import funnelHeader from "@/common-components/funnel-header/funnel-header";
import funnelFooter from "@/common-components/funnel-footer/funnel-footer";
import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header";
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import funnelFooter from "@/fmg-components/funnel-footer/funnel-footer";
import vehicleBanner from "@/fmg-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import alert from "@/ux-components/alert/alert";
import textboxQuestion from "@/common-components/textbox-question/textbox-question";
import textBlock from "@/common-components/text-block/text-block";
import textboxQuestion from "@/digital-components/textbox-question/textbox-question";
import textBlock from "@/digital-components/text-block/text-block";
// Supporting files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
@ -120,7 +120,7 @@ defineRule("email-address-required", required(errorMessages.EMAIL_ADDRESS_REQUIR
defineRule(
"email-address-format",
regex(
/^([a-zA-Z0-9_\-.+]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,})$/,
/^([a-zA-Z0-9_\-.+]+)@([a-zA-Z0-9-]+)\.([a-zA-Z]{2,})$/,
errorMessages.EMAIL_ADDRESS_FORMAT
)
);
@ -152,7 +152,7 @@ export default {
data() {
return {
licensePlate: this.getLicensePlateFromStore(),
registrationZipCode: this.getRegistrationZipFromStore(),
registrationZipCode: this.getRegistrationZipFromStore() ?? this.$route.query.zipcode,
email: this.getEmailFromStore(),
serviceZipCode: this.getServiceZipFromStore(),
displayNonServiceableZipAlert: false,

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,12 @@
<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm" v-slot="{ meta }">
<div class="page-container-grouped-styles quote">
<div class="page-container-grouped-styles">
<loadingModal ref="loadingModal" />
<funnelHeader cmsWidgetName="FunnelHeaderWidget" />
<vehicleBanner
cmsWidgetName="VehicleBannerWidget"
:displayGenericVehicleImage="false" />
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" />
<funnelSubHeader class="mb-5" cmsWidgetName="FunnelSubHeaderWidget" />
<div class="fade-on-route-transition sub-container make-tall">
<cashOrInsuranceQuestion
ref="cashOrInsurance"
@ -21,6 +21,7 @@
:availableLineItems="availableLineItems"
:isInsuranceSelected="isInsuranceSelected"
@vapsItemsSelected="vapsItemsSelectedAction"
v-on="{ 'buttonEvent.openModal': openModalAction }"
validationRules="option-required"
isRequired />
@ -29,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"
@ -46,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";
@ -102,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);
@ -130,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 &&
@ -174,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,
@ -189,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: {
@ -201,18 +230,8 @@ export default {
textBlock,
cashOrInsuranceQuestion,
servicePackageQuestion,
modal,
contentGroupModal,
loadingModal,
},
};
</script>
<style lang="scss">
.quote {
.funnel-sub-header {
.sub-text {
margin-bottom: 24px;
}
}
}
</style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,30 @@
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]);
}

View file

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

View file

@ -0,0 +1,346 @@
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);
};
jest.mock(
"@/layouts/service-location/helpers/service-location-helper/service-location-helper",
() => ({
getPricedMobileFeePart: jest.fn((mockServiceZipCode) => {
return mockGetPricedMobileFeePart(mockServiceZipCode);
}),
})
);
describe("mobile-location-modal-questions.vue", () => {
it("Should copy the modelValue to the internalModel when the component is mounted", async () => {
// Arrange
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "555 Some St",
apartmentNumberOrBusinessName: "Apt 1",
city: "Funkytown",
state: "OH",
zipCode: "55555",
},
isVehicleProtected: true,
serviceZipCode: "55555",
};
const { wrapper } = setupMocks({
mixins: [mockMixin],
props: {
modelValue: mobileLocationQuestions,
},
mountOptions: {
attachTo: document.body,
},
});
// Assert
expect(wrapper.vm.internalModel.addressQuestions.streetAddress).toEqual("555 Some St");
expect(wrapper.vm.internalModel.addressQuestions.apartmentNumberOrBusinessName).toEqual(
"Apt 1"
);
expect(wrapper.vm.internalModel.addressQuestions.city).toEqual("Funkytown");
expect(wrapper.vm.internalModel.addressQuestions.state).toEqual("OH");
expect(wrapper.vm.internalModel.addressQuestions.zipCode).toEqual("55555");
expect(wrapper.vm.internalModel.isVehicleProtected).toEqual(true);
expect(wrapper.vm.internalModel.serviceZipCode).toEqual("55555");
});
it("Should open the modal when the 'Enter your service address' link is clicked", async () => {
// Arrange
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "555 Some St",
apartmentNumberOrBusinessName: "Apt 1",
city: "Funkytown",
state: "OH",
zipCode: "55555",
},
isVehicleProtected: true,
serviceZipCode: "55555",
};
const { wrapper } = setupMocks({
mixins: [mockMixin],
props: {
modelValue: mobileLocationQuestions,
},
mountOptions: {
attachTo: document.body,
},
});
wrapper.vm.$refs.MobileLocationModalWidget.openModal = jest.fn();
const mobileLocationLink = wrapper.findComponent({ ref: "mobileLocationLink" });
// // Act
await mobileLocationLink.trigger("click-event");
// // Assert
expect(wrapper.vm.$refs.MobileLocationModalWidget.openModal).toHaveBeenCalled();
});
it("Should emit update:modelValue on setMobileLocation for a valid address and vehicle protection answer", async () => {
// Arrange
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
isVehicleProtected: true,
serviceZipCode: "",
};
const newMobileLocationQuestions = {
addressQuestions: {
streetAddress: "555 Some St",
apartmentNumberOrBusinessName: "Apt 1",
city: "Funkytown",
state: "OH",
zipCode: "55555",
},
isVehicleProtected: true,
serviceZipCode: "55555",
};
const wrapper = shallowMount(mobileLocationModalQuestions, {
mixins: [mockMixin],
props: {
modelValue: mobileLocationQuestions,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
wrapper.vm.$refs.MobileLocationModalWidget.closeModal = jest.fn();
// Act
wrapper.vm.internalModel = newMobileLocationQuestions;
await wrapper.vm.setMobileLocation();
let expectedEmit = [[newMobileLocationQuestions]];
// Assert
expect(wrapper.emitted("update:modelValue")).toEqual(expectedEmit);
});
it("Should not emit update:modelValue on setMobileLocation for an invalid address zip code", async () => {
// Arrange
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
isVehicleProtected: true,
serviceZipCode: "",
};
const newMobileLocationQuestions = {
addressQuestions: {
streetAddress: "555 Some St",
apartmentNumberOrBusinessName: "Apt 1",
city: "Funkytown",
state: "OH",
zipCode: "61000",
},
isVehicleProtected: true,
serviceZipCode: "61000",
};
const wrapper = shallowMount(mobileLocationModalQuestions, {
mixins: [mockMixin],
props: {
modelValue: mobileLocationQuestions,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
wrapper.vm.$refs.MobileLocationModalWidget.closeModal = jest.fn();
wrapper.vm.resetModalButtonStyle = jest.fn();
// Act
wrapper.vm.internalModel = newMobileLocationQuestions;
await wrapper.vm.setMobileLocation();
// Assert
expect(wrapper.emitted("update:modelValue")).not.toBeTruthy();
});
it("Should display invalid zip alert for invalid address zip inputs", async () => {
// Arrange
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
isVehicleProtected: true,
serviceZipCode: "",
};
const newMobileLocationQuestions = {
addressQuestions: {
streetAddress: "555 Some St",
apartmentNumberOrBusinessName: "Apt 1",
city: "Funkytown",
state: "OH",
zipCode: "11111",
},
isVehicleProtected: true,
serviceZipCode: "11111",
};
const wrapper = shallowMount(mobileLocationModalQuestions, {
mixins: [mockMixin],
props: {
modelValue: mobileLocationQuestions,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
wrapper.vm.internalModel = newMobileLocationQuestions;
wrapper.vm.resetModalButtonStyle = jest.fn();
// Act
await wrapper.vm.setMobileLocation();
// Assert
expect(wrapper.vm.displayInvalidZipAlert).toBe(true);
});
});
function setupMocks({ mountOptions, mixins, props, isShallowMount = true }) {
const resultingMountOptions = getMountOptions({
...mountOptions,
mixins,
});
if (props) resultingMountOptions.propsData = props;
const wrapper = isShallowMount
? shallowMount(mobileLocationModalQuestions, resultingMountOptions)
: mount(mobileLocationModalQuestions, resultingMountOptions);
return { wrapper };
}

View file

@ -0,0 +1,239 @@
<template>
<transition name="fade" mode="out-in">
<div class="mobile-location-questions">
<div class="text-center">
<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>
<modal
:ref="modalName"
:headerText="modalHeaderText"
:footerButtonText="modalFooterText"
@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";
import store from "@/store";
// Helpers
import { deepClone } from "@/layouts/service-location/helpers/object-cloning-helper/object-cloning-helper";
import { getPricedMobileFeePart } from "@/layouts/service-location/helpers/service-location-helper/service-location-helper";
export default {
name: "mobile-location-modal-questions",
emits: ["update:modelValue", "set-mobile-fee-part"],
data() {
return {
internalModel: deepClone(this.modelValue),
displayInvalidZipAlert: false,
};
},
props: {
modelValue: {
type: Object,
default: () => ({
addressQuestions: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
isVehicleProtected: null,
}),
},
mobileFeePart: {
type: Object,
default: () => ({}),
},
isZipServiceableMobile: Boolean,
isZipServiceableInShop: Boolean,
linkWidgetName: String,
modalWidgetName: String,
alertNonServiceableZipWidgetName: String,
alertInvalidZipWidgetName: 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 !== ""
) {
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();
},
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].form.resetForm({
values: {
state: updatedServiceZipCodeInfo.state,
zipCode: updatedServiceZipCodeInfo.zipCode,
isVehicleProtected: updatedServiceZipCodeInfo.isVehicleProtected,
},
});
},
resetModalButtonStyle() {
this.$refs[this.modalName].resetButtonStyle();
},
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);
// emit it to parent
this.$emit("set-mobile-fee-part", mobileFeePart);
// Update the page level model
this.$emit("update:modelValue", this.internalModel);
this.closeModal();
}
},
},
watch: {
modelValue: {
handler(newValue) {
this.internalModel = deepClone(newValue);
this.resetComponent({
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>
.update-zip-text-link::before {
content: "";
display: inline-block;
width: 13px;
height: 16px;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 13 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.49635 1.00142e-07C5.64734 -0.000153295 4.80722 0.175918 4.0274 0.517444C3.24757 0.858969 2.54443 1.35877 1.96099 1.98626C0.765713 3.27588 0.0999756 4.98141 0.0999756 6.75394C0.0999756 8.52646 0.765713 10.232 1.96099 11.5216L5.98324 15.777C6.04954 15.8475 6.12918 15.9036 6.21736 15.9419C6.30555 15.9802 6.40045 16 6.49635 16C6.59225 16 6.68716 15.9802 6.77534 15.9419C6.86353 15.9036 6.94317 15.8475 7.00946 15.777L11.0317 11.52C12.2391 10.2383 12.909 8.52914 12.8999 6.75394C12.9094 4.97818 12.2394 3.26832 11.0317 1.98626C10.4481 1.35899 9.74493 0.859347 8.96514 0.517839C8.18535 0.176331 7.34532 0.000130509 6.49635 1.00142e-07V1.00142e-07ZM6.49635 9.13131C6.02507 9.13131 5.56437 8.98913 5.17251 8.72275C4.78065 8.45637 4.47524 8.07776 4.29488 7.63479C4.11453 7.19181 4.06734 6.70438 4.15928 6.23412C4.25123 5.76387 4.47817 5.33191 4.81142 4.99287C5.14467 4.65384 5.56925 4.42295 6.03148 4.32941C6.49371 4.23587 6.97282 4.28388 7.40823 4.46736C7.84364 4.65085 8.21579 4.96157 8.47762 5.36023C8.73945 5.7589 8.87921 6.2276 8.87921 6.70707C8.87921 7.34974 8.62837 7.96611 8.18185 8.4207C7.73532 8.87528 7.12964 9.13088 6.49794 9.13131H6.49635Z' fill='%231574A1'/%3E%3C/svg%3E%0A");
background-size: contain;
vertical-align: middle;
margin-right: 0.5em;
}
.address-questions {
margin-top: 0em;
}
#mobileLocationLinkPromptId {
white-space: pre-line;
}
</style>

View file

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

View file

@ -0,0 +1,64 @@
<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">
.question-text {
margin-top: 1.5rem;
margin-bottom: 1rem;
font-size: 1rem;
line-height: 1.625rem;
& > span {
text-align: left;
}
}
</style>

View file

@ -1,3 +1,434 @@
describe("service-location.vue", () => {
test.todo("test this");
// Components
import serviceLocation from "@/layouts/service-location/service-location.vue";
// Supporting files
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper";
import store from "@/store";
import baseMixin from "@/mixins/base-mixin";
// Define Mocks
jest.mock("@/helpers/cms-content-helper", () => ({
fetchCmsContentForPage: jest.fn(() => {
return Promise.resolve("content");
}),
}));
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);
};
jest.mock(
"@/layouts/service-location/helpers/service-location-helper/service-location-helper",
() => ({
getPricedMobileFeePart: jest.fn((mockServiceZipCode) => {
return mockGetPricedMobileFeePart(mockServiceZipCode);
}),
})
);
jest.spyOn(baseMixin.methods, "getZipCodeData").mockImplementation((serviceZipCode) => {
return new Promise((resolve) => {
let mockContainsMilitaryBase = false;
if (serviceZipCode === 45433 || serviceZipCode === "45433") {
mockContainsMilitaryBase = true;
}
resolve({
containsMilitaryBase: mockContainsMilitaryBase,
isValid: true,
isServiceable: true,
state: "OH",
zipCodeCtu: "01820",
});
});
});
jest.mock("@/store", () => ({
commit: jest.fn(),
dispatch: jest.fn(),
}));
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,
isValid: true,
state: "OH",
zipCodeCtu: "01820",
});
}
return Promise.resolve({
containsMilitaryBase: 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;
}),
onSubmit: jest.fn(),
onInvalidSubmit: jest.fn(),
},
};
beforeEach(() => {
store.getters = {
lineItems: {
supportingItems: [
{
description: null,
kitPrice: 0,
laborAmount: 0,
partNumber: "SUPPLIES-REPAIR",
partType: "REPAIR FEE",
sellingPrice: 7.99,
},
],
},
order: {
serviceLocation: {
zipCode: "43235",
state: "OH",
},
},
damage: {
isRepair: false,
},
payment: {
isInsurance: false,
},
vehicle: {
registration: {
address: "5555 Sulgrave Dr",
city: "New Albany",
state: "OH",
zipCode: "43054",
},
},
};
});
describe("service-location.vue", () => {
describe("beforeRouteEnter", () => {
test("on load sets the mobile fee part when an service zip code has already been provided", async () => {
// Arrange
const { wrapper } = setupMocks({});
const mobileFeePart = {
partNumber: "MOBILE FEE",
description: "MOBILE FEE",
partType: "FEE",
laborAmount: 49.99,
sellingPrice: 0,
kitPrice: 0,
};
// Act
await serviceLocation.beforeRouteEnter.call(
wrapper.vm,
{ query: { fmgPage: "serviceLocation" } },
undefined,
(c) => c(wrapper.vm)
);
// Assert
expect(wrapper.vm.mobileFeePart).toStrictEqual(mobileFeePart);
});
});
describe("arePagePrerequisitesValid", () => {
test("No prerequisites set: Should return false.", () => {
const { wrapper } = setupMocks({});
store.getters = {
lineItems: {
supportingItems: null,
},
order: {
serviceLocation: {
zipCode: null,
},
},
payment: {
isInsurance: null,
},
};
let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
expect(arePagePrerequisitesValid).toBe(false);
});
test("All prerequisites set: Should return true.", () => {
const { wrapper } = setupMocks({});
let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
expect(arePagePrerequisitesValid).toBe(true);
});
test("Some prerequisites set: Should return false.", () => {
const { wrapper } = setupMocks({});
store.getters.order.serviceLocation.zipCode = null;
let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
expect(arePagePrerequisitesValid).toBe(false);
});
});
describe("updating service zip", () => {
test("updates the page model after providing the service zip code", () => {
// Arrange
const { wrapper } = setupMocks({
mixins: [mockMixin],
});
wrapper.vm.$refs.mobileLocationModalQuestions.resetComponent = jest.fn();
const newServiceZipCodeQuestion = {
zipCode: "61606",
state: "IL",
};
const serviceZipCodeComponent = wrapper.findComponent({
ref: "serviceZipCodeQuestion",
});
// Act
serviceZipCodeComponent.vm.$emit("update:modelValue", newServiceZipCodeQuestion);
// Assert
expect(wrapper.vm.serviceZipCodeQuestion).toStrictEqual(newServiceZipCodeQuestion);
});
test("resets mobile location when service zip code is updated", () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.$refs.mobileLocationModalQuestions.resetComponent = jest.fn();
wrapper.vm.$refs.serviceZipCodeQuestion.resetMobileFeePart = jest.fn();
const newServiceZipCodeQuestion = {
zipCode: "61606",
state: "IL",
};
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "5555 Sulgrave Dr",
apartmentNumberOrBusinessName: "Apt 1",
city: "New Albany",
state: "OH",
zipCode: "43054",
},
isVehicleProtected: true,
};
wrapper.vm.mobileLocationQuestions = mobileLocationQuestions;
const newMobileLocationQuestions = {
addressQuestions: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "IL",
zipCode: "61606",
},
isVehicleProtected: null,
};
const serviceZipCodeComponent = wrapper.findComponent({
ref: "serviceZipCodeQuestion",
});
// Act
serviceZipCodeComponent.vm.$emit("update:modelValue", newServiceZipCodeQuestion);
// Assert
expect(wrapper.vm.mobileLocationQuestions).toStrictEqual(newMobileLocationQuestions);
});
test("displays military zip message when zip is updated", () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.$refs.mobileLocationModalQuestions.resetComponent = jest.fn();
expect(wrapper.vm.zipContainsMilitaryBase).toBe(false);
const newServiceZipCodeQuestion = {
zipCode: "45433",
state: "OH",
};
// Assert
wrapper.setData({ zipCode: newServiceZipCodeQuestion.zipCode });
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.zipContainsMilitaryBase).toBe(true);
});
});
});
describe("updating mobile location", () => {
test("updates the page model after providing the mobile location", () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.$refs.mobileLocationModalQuestions.resetComponent = jest.fn();
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
isVehicleProtected: null,
};
wrapper.vm.mobileLocationQuestions = mobileLocationQuestions;
const newMobileLocationQuestions = {
addressQuestions: {
streetAddress: "5555 Sulgrave Dr",
apartmentNumberOrBusinessName: "Apt 1",
city: "New Albany",
state: "OH",
zipCode: "43054",
},
isVehicleProtected: true,
};
const mobileLocationComponent = wrapper.findComponent({
ref: "mobileLocationModalQuestions",
});
// Act
mobileLocationComponent.vm.$emit("update:modelValue", newMobileLocationQuestions);
// Assert
expect(wrapper.vm.mobileLocationQuestions).toStrictEqual(newMobileLocationQuestions);
});
test("resets service zip code when mobile location is updated", async () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.$refs.mobileLocationModalQuestions.resetComponent = jest.fn();
wrapper.vm.$refs.serviceZipCodeQuestion.resetMobileFeePart = jest.fn();
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
isVehicleProtected: null,
mobileFeePart: null,
};
wrapper.vm.mobileLocationQuestions = mobileLocationQuestions;
const newMobileLocationQuestions = {
addressQuestions: {
streetAddress: "5555 Rustic Dr",
apartmentNumberOrBusinessName: "Apt 1",
city: "Westerville",
state: "OH",
zipCode: "43081",
},
isVehicleProtected: true,
mobileFeePart: null,
};
const mobileLocationComponent = wrapper.findComponent({
ref: "mobileLocationModalQuestions",
});
wrapper.vm.serviceZipCodeQuestion = {
zipCode: "61606",
state: "IL",
};
const newServiceZipCodeInfo = {
state: "OH",
zipCode: "43081",
};
// Act
mobileLocationComponent.vm.$emit("update:modelValue", newMobileLocationQuestions);
// Assert
expect(wrapper.vm.serviceZipCodeQuestion).toStrictEqual(newServiceZipCodeInfo);
});
});
});
function setupMocks({ mountOptionsMockData = {} }) {
const apiResponses = {};
const apiPromise = Promise.resolve(apiResponses);
const mountOptions = getMountOptions(mountOptionsMockData);
const wrapper = shallowMount(serviceLocation, mountOptions);
wrapper.vm.setCmsContent = baseMixin.methods.setCmsContent;
return { wrapper, apiPromise };
}

View file

@ -1,11 +1,32 @@
<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"
@set-mobile-fee-part="setMobileFeePart"
linkWidgetName="ServiceZipLinkWidget"
modalWidgetName="ServiceZipModalWidget" />
<alert
class="my-4"
cmsWidgetName="AlertMilitaryBaseZipWidget"
v-if="zipContainsMilitaryBase"
alertClass="alert-warning" />
<mobileLocationModalQuestions
v-model="mobileLocationQuestions"
:mobileFeePart="mobileFeePart"
@set-mobile-fee-part="setMobileFeePart"
ref="mobileLocationModalQuestions"
linkWidgetName="MobileLocationLinkWidget"
modalWidgetName="MobileLocationModalWidget" />
<funnel-footer
cmsWidgetName="FunnelFooterWidget"
:isForwardActionDisabled="!meta.valid"
ref="funnelFooter"
:isForwardActionDisabled="!meta.valid || shouldDisableForwardAction"
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction" />
</div>
@ -14,23 +35,53 @@
<script>
// Components
import funnelHeader from "@/common-components/funnel-header/funnel-header";
import funnelFooter from "@/common-components/funnel-footer/funnel-footer";
import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header";
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 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";
// 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 } from "@/layouts/service-location/helpers/service-location-helper/service-location-helper";
import store from "@/store";
export default {
name: "service-location",
data() {
return {};
return {
streetAddress: this.getServiceAddressFromStore(),
apartmentNumberOrBusinessName: "",
city: this.getServiceCityFromStore(),
state: this.getServiceStateFromStore(),
zipCode: this.getServiceZipCodeFromStore(),
isVehicleProtected: null,
isZipServiceableMobile: null,
isZipServiceableInShop: null,
mobileFeePart: null,
zipContainsMilitaryBase: false,
};
},
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
const serviceZipCode = store.getters.order.serviceLocation.zipCode;
const serviceType = store.getters.damage.isRepair ? "Repair" : "Replace";
const parentAccountNumber = store.getters.payment.parentAccountNumber;
const billToAccountNumber = 1;
const mobileFeePartPromise = getPricedMobileFeePart(
serviceZipCode,
serviceType,
parentAccountNumber,
billToAccountNumber
);
// Settle promises and get results
const promiseResultMap = [
@ -38,6 +89,14 @@ export default {
resultKey: "cmsContent",
promise: cmsContentPromise,
},
{
resultKey: "mobileFeePart",
promise: mobileFeePartPromise,
},
{
resultKey: "zipCodeData",
promise: baseMixin.methods.getZipCodeData(serviceZipCode),
},
];
const resultMap = await settleAllPromises(promiseResultMap);
@ -45,21 +104,115 @@ export default {
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.setData(resultMap.mobileFeePart);
vm.zipContainsMilitaryBase = resultMap.zipCodeData.containsMilitaryBase;
vm.zipCode = serviceZipCode;
});
},
computed: {
serviceZipCodeQuestion: {
get: function () {
return {
state: this.state,
zipCode: this.zipCode,
};
},
set: function (newValue) {
if (newValue.zipCode !== this.zipCode) {
this.resetMobileLocation();
}
this.state = newValue.state;
this.zipCode = newValue.zipCode;
},
},
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;
},
},
shouldDisableForwardAction() {
return this.zipContainsMilitaryBase;
},
},
methods: {
arePagePrerequisitesValid() {
return true;
return (
store.getters.lineItems.supportingItems !== null &&
store.getters.order.serviceLocation.zipCode !== null &&
store.getters.payment.isInsurance !== null
);
},
setData(mobileFeePart) {
if (mobileFeePart) {
this.mobileFeePart = mobileFeePart;
}
},
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 = "";
backButtonAction() {},
forwardButtonAction() {},
this.isVehicleProtected = null;
},
backButtonAction() {
this.$router.navigateWithoutSaving(this.navigationScenarios.CLICKED_BACK, this.$route);
},
forwardButtonAction() {
navigateToHeritageFunnel({ loadingModal: this.$refs.loadingModal });
},
},
watch: {
zipCode(current, previous) {
if (current !== previous) {
baseMixin.methods.getZipCodeData(current).then((r) => {
this.zipContainsMilitaryBase = r.containsMilitaryBase;
});
}
},
},
components: {
alert,
serviceZipModalQuestion,
mobileLocationModalQuestions,
funnelHeader,
funnelFooter,
funnelSubHeader,
Form,
loadingModal,
},
};
</script>

View file

@ -0,0 +1,281 @@
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);
};
jest.mock(
"@/layouts/service-location/helpers/service-location-helper/service-location-helper",
() => ({
getPricedMobileFeePart: jest.fn((mockServiceZipCode) => {
return mockGetPricedMobileFeePart(mockServiceZipCode);
}),
})
);
const linkWidgetName = "linkWidgetName";
const modalWidgetName = "modalWidgetName";
const mockLinkCmsContent = {
BodyText: "Sample link body text here.",
};
const mockModalCmsContent = {
FooterText: "Sample modal footer text here.",
};
const mockMixin = {
methods: {
getCmsContent: jest.fn((widgetName, cmsFieldName) => {
if (widgetName === linkWidgetName) {
return mockLinkCmsContent[cmsFieldName];
}
if (widgetName === modalWidgetName) {
return mockModalCmsContent[cmsFieldName];
}
return null;
}),
getZipCodeData: jest.fn((zip) => {
if (zip === "43235") {
return {
containsMilitaryBase: false,
isValid: true,
state: "OH",
zipCodeCtu: "01820",
};
}
if (zip === "61606") {
return {
containsMilitaryBase: false,
isValid: true,
state: "IL",
zipCodeCtu: "01526",
};
}
return {
containsMilitaryBase: false,
isValid: false,
state: null,
zipCodeCtu: null,
};
}),
},
};
describe("service-zip-modal-question.vue", () => {
it("Initial state on load with no existing location info", async () => {
let serviceZipCodeQuestion = {
state: "",
zipCode: "",
};
// Arrange
const wrapper = mount(serviceZipModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: serviceZipCodeQuestion,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Assert
expect(wrapper.html()).toEqual(expect.stringContaining(mockLinkCmsContent["BodyText"]));
});
it("Initial state on load with existing location info", async () => {
// Arrange
let serviceZipCodeQuestion = {
state: "OH",
zipCode: "43235",
};
const wrapper = mount(serviceZipModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: serviceZipCodeQuestion,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Assert
expect(wrapper.html()).not.toEqual(expect.stringContaining(mockLinkCmsContent["BodyText"]));
});
it("Should emit update:modelValue on setZipCode for a valid, serviceable zip", async () => {
// Arrange
let serviceZipCodeQuestion = {
state: "IL",
zipCode: "61606",
};
const zip = "43235";
const wrapper = mount(serviceZipModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: serviceZipCodeQuestion,
mobileFeePart: {},
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Act
wrapper.vm.internalModel.zipCode = zip;
await wrapper.vm.setZipCode();
let expectedEmit = [[{ state: "OH", zipCode: "43235" }]];
// Assert
expect(wrapper.emitted("update:modelValue")).toEqual(expectedEmit);
});
it("Should not emit update:modelValue on setZipCode for an invalid zip", async () => {
// Arrange
let serviceZipCodeQuestion = {
state: "IL",
zipCode: "61606",
};
const zip = "";
const wrapper = mount(serviceZipModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: serviceZipCodeQuestion,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Act
wrapper.vm.internalModel.zipCode = zip;
await wrapper.vm.setZipCode();
// Assert
expect(wrapper.emitted("update:modelValue")).not.toBeTruthy();
});
it("Should display invalid zip alerts for invalid ip inputs", async () => {
// Arrange
let serviceZipCodeQuestion = {
state: "IL",
zipCode: "61606",
};
const zip = "11111";
const wrapper = mount(serviceZipModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: serviceZipCodeQuestion,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Act
wrapper.vm.internalModel.zipCode = zip;
await wrapper.vm.setZipCode();
// Assert
expect(wrapper.vm.displayInvalidZipAlert).toBe(true);
});
it("Should reset all alerts on onModalClosed", async () => {
// Arrange
let serviceZipCodeQuestion = {
state: "IL",
zipCode: "61606",
};
const zip = "";
const wrapper = mount(serviceZipModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: serviceZipCodeQuestion,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Act
wrapper.vm.internalModel.zipCode = zip;
await wrapper.vm.setZipCode();
wrapper.vm.onModalClosed();
// Assert
expect(wrapper.vm.displayInvalidZipAlert).toBe(false);
});
it("Should prepopulate the zip on onModalOpened", async () => {
// Arrange
let serviceZipCodeQuestion = {
state: "IL",
zipCode: "61606",
};
const zip = "123";
const wrapper = mount(serviceZipModalQuestion, {
mixins: [mockMixin],
props: {
modelValue: serviceZipCodeQuestion,
linkWidgetName: linkWidgetName,
modalWidgetName: modalWidgetName,
},
attachTo: document.body,
});
// Act
wrapper.vm.internalModel.zipCode = zip;
wrapper.vm.onModalOpened();
// Assert
expect(wrapper.vm.internalModel.zipCode).toEqual("61606");
});
});

View file

@ -0,0 +1,197 @@
<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"
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 store from "@/store";
import { getPricedMobileFeePart } from "@/layouts/service-location/helpers/service-location-helper/service-location-helper";
export default {
name: "service-zip-modal-question",
emits: ["update:modelValue", "set-mobile-fee-part"],
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() {
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);
// emit it to parent
this.$emit("set-mobile-fee-part", mobileFeePart);
// Update the page level model
this.$emit("update:modelValue", this.internalModel);
this.closeModal();
}
},
},
watch: {
modelValue(newValue) {
this.internalModel = this.copyModel(newValue);
},
"internalModel.zipCode": {
handler() {
this.resetsOnZipInput();
},
},
},
components: {
textLink,
serviceZipQuestion,
modal,
alert,
},
};
</script>
<style lang="scss" scoped>
.update-zip-text-link::before {
content: "";
display: inline-block;
width: 13px;
height: 16px;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 13 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.49635 1.00142e-07C5.64734 -0.000153295 4.80722 0.175918 4.0274 0.517444C3.24757 0.858969 2.54443 1.35877 1.96099 1.98626C0.765713 3.27588 0.0999756 4.98141 0.0999756 6.75394C0.0999756 8.52646 0.765713 10.232 1.96099 11.5216L5.98324 15.777C6.04954 15.8475 6.12918 15.9036 6.21736 15.9419C6.30555 15.9802 6.40045 16 6.49635 16C6.59225 16 6.68716 15.9802 6.77534 15.9419C6.86353 15.9036 6.94317 15.8475 7.00946 15.777L11.0317 11.52C12.2391 10.2383 12.909 8.52914 12.8999 6.75394C12.9094 4.97818 12.2394 3.26832 11.0317 1.98626C10.4481 1.35899 9.74493 0.859347 8.96514 0.517839C8.18535 0.176331 7.34532 0.000130509 6.49635 1.00142e-07V1.00142e-07ZM6.49635 9.13131C6.02507 9.13131 5.56437 8.98913 5.17251 8.72275C4.78065 8.45637 4.47524 8.07776 4.29488 7.63479C4.11453 7.19181 4.06734 6.70438 4.15928 6.23412C4.25123 5.76387 4.47817 5.33191 4.81142 4.99287C5.14467 4.65384 5.56925 4.42295 6.03148 4.32941C6.49371 4.23587 6.97282 4.28388 7.40823 4.46736C7.84364 4.65085 8.21579 4.96157 8.47762 5.36023C8.73945 5.7589 8.87921 6.2276 8.87921 6.70707C8.87921 7.34974 8.62837 7.96611 8.18185 8.4207C7.73532 8.87528 7.12964 9.13088 6.49794 9.13131H6.49635Z' fill='%231574A1'/%3E%3C/svg%3E%0A");
background-size: contain;
vertical-align: middle;
margin-right: 0.5em;
}
</style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
fmg
<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm" v-slot="{ meta }">
<div class="page-container-grouped-styles">
@ -58,10 +59,10 @@
<script>
// Components
import funnelHeader from "@/common-components/funnel-header/funnel-header";
import funnelFooter from "@/common-components/funnel-footer/funnel-footer";
import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header";
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import funnelFooter from "@/fmg-components/funnel-footer/funnel-footer";
import vehicleBanner from "@/fmg-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import sideDoorOptions from "@/layouts/vehicle-damage/side-door-options/side-door-options";
import damageLocationQuestion from "@/layouts/vehicle-damage/damage-location-question/damage-location-question";
import windshieldOptions from "@/layouts/vehicle-damage/windshield-options/windshield-options";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm">
<div class="page-container-grouped-styles vehicle-parts">
<div class="page-container-grouped-styles vehicle-parts small-question-text">
<loadingModal ref="loadingModal" />
<funnelHeader ref="funnelHeader" cmsWidgetName="FunnelHeaderWidget" />
<vehicleBanner
@ -45,12 +45,12 @@
<script>
// Components
import glassPartQuestion from "@/layouts/vehicle-parts/glass-part-question/glass-part-question";
import funnelHeader from "@/common-components/funnel-header/funnel-header";
import vehicleBanner from "@/common-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/common-components/funnel-sub-header/funnel-sub-header";
import funnelFooter from "@/common-components/funnel-footer/funnel-footer";
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import vehicleBanner from "@/fmg-components/vehicle-banner/vehicle-banner";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import funnelFooter from "@/fmg-components/funnel-footer/funnel-footer";
import alert from "@/ux-components/alert/alert";
import loadingModal from "@/common-components/loading-modal/loading-modal.vue";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
// Supporting Files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
@ -149,10 +149,10 @@ export default {
methods: {
arePagePrerequisitesValid() {
// Check if isRepair is populated and if the pageData we need is here (Parts data)
const vehiclePartsFromPageData = store.getters.pageData(fmgPageValues.VEHICLE_PARTS);
return (
store.getters.damage.isRepair != null &&
store.getters.pageData(fmgPageValues.VEHICLE_PARTS) &&
Object.keys(store.getters.pageData(fmgPageValues.VEHICLE_PARTS)).length !== 0
vehiclePartsFromPageData?.partsOrQuestions?.some((part) => part?.glassName)
);
},
async forwardButtonAction() {

View file

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

View file

@ -7,7 +7,8 @@
<funnelSubHeader
cmsWidgetName="FunnelSubHeaderWidget"
:hasBackButton="true"
@click-event="backButtonAction" />
@click-event="backButtonAction"
headerColor="light-header" />
<div class="fade-on-route-transition">
<styleQuestion
v-model="selectedStyle"
@ -23,9 +24,9 @@
// Components
import baseMixin from "@/mixins/base-mixin.js";
import styleQuestion from "@/layouts/vehicle-style/style-question/style-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";

View file

@ -4,7 +4,7 @@
<div class="select-car">
<div class="select-car-form rounded text-center">
<vehicleBanner cmsWidgetName="VehicleBannerWidget" displayGenericVehicleImage />
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" />
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" headerColor="light-header" />
<div class="fade-on-route-transition">
<yearQuestion
class="fade-on-route-transition"
@ -20,9 +20,9 @@
<script>
// Components
import yearQuestion from "@/layouts/vehicle-year/year-question/year-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";

View file

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

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