Merge pull request #1020 from Safelite/feature/CSR-1088

Feature/csr 1088
This commit is contained in:
Leah Schumann 2023-03-27 10:37:21 -04:00 committed by GitHub
commit 002aebb52b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 155 additions and 59 deletions

View file

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

View file

@ -1,16 +1,14 @@
jest.mock("vee-validate", () => ({ jest.mock("vee-validate", () => ({
useForm: jest.fn(), useForm: jest.fn(),
useIsFormTouched: jest.fn(),
useIsFormDirty: jest.fn(),
useIsFormValid: jest.fn(),
})); }));
const mockValidate = (returnValue) => jest.fn(async () => Promise.resolve({ valid: returnValue })); const mockValidate = (returnValue) => jest.fn(async () => Promise.resolve({ valid: returnValue }));
const mockMeta = (returnValue) => jest.fn(async () => Promise.resolve(returnValue));
import { shallowMount } from "@vue/test-utils"; import { shallowMount } from "@vue/test-utils";
import modal from "./modal"; import modal from "./modal";
import crypto from "crypto"; import crypto from "crypto";
import { useForm, useIsFormDirty, useIsFormTouched, useIsFormValid } from "vee-validate"; import { useForm } from "vee-validate";
import { Modal } from "bootstrap"; import { Modal } from "bootstrap";
const footerButtonText = "Sample footer text here."; const footerButtonText = "Sample footer text here.";
@ -21,6 +19,18 @@ global.crypto = crypto;
describe("modal.vue", () => { describe("modal.vue", () => {
it("Should display modal header text when headerText is defined", async () => { it("Should display modal header text when headerText is defined", async () => {
// Arrange / Act // Arrange / Act
const fakeMeta = {
touched: true,
dirty: true,
valid: true,
validated: true,
};
useForm.mockReturnValue({
meta: mockMeta(fakeMeta),
validate: mockValidate(true),
});
const wrapper = shallowMount(modal, { const wrapper = shallowMount(modal, {
props: { props: {
headerText: headerText, headerText: headerText,
@ -35,6 +45,17 @@ describe("modal.vue", () => {
it("Should display footer button text when footerButtonText is defined", async () => { it("Should display footer button text when footerButtonText is defined", async () => {
// Arrange / Act // Arrange / Act
const fakeMeta = {
touched: true,
dirty: true,
valid: true,
validated: true,
};
useForm.mockReturnValue({
meta: mockMeta(fakeMeta),
validate: mockValidate(true),
});
const wrapper = shallowMount(modal, { const wrapper = shallowMount(modal, {
props: { props: {
footerButtonText: footerButtonText, footerButtonText: footerButtonText,
@ -48,7 +69,15 @@ describe("modal.vue", () => {
it("Should emit 'footer-button-event' if the form is valid", async () => { it("Should emit 'footer-button-event' if the form is valid", async () => {
// Arrange // Arrange
const fakeMeta = {
touched: true,
dirty: true,
valid: true,
validated: true,
};
useForm.mockReturnValue({ useForm.mockReturnValue({
meta: mockMeta(fakeMeta),
validate: mockValidate(true), validate: mockValidate(true),
}); });
@ -72,7 +101,15 @@ describe("modal.vue", () => {
it("Should not emit 'footer-button-event' if the form is invalid", async () => { it("Should not emit 'footer-button-event' if the form is invalid", async () => {
// Arrange // Arrange
const fakeMeta = {
touched: true,
dirty: true,
valid: false,
validated: true,
};
useForm.mockReturnValue({ useForm.mockReturnValue({
meta: mockMeta(fakeMeta),
validate: mockValidate(false), validate: mockValidate(false),
}); });
@ -96,11 +133,17 @@ describe("modal.vue", () => {
it("Should have a disabled footer button when the form has not been touched", async () => { it("Should have a disabled footer button when the form has not been touched", async () => {
// Arrange / Act // Arrange / Act
useForm.mockReturnValue({ const fakeMeta = {
validate: mockValidate(false), touched: true,
}); dirty: true,
valid: true,
validated: true,
};
useIsFormTouched.mockReturnValue(false); useForm.mockReturnValue({
meta: mockMeta(fakeMeta),
validate: mockValidate(true),
});
const resetButtonStyle = jest.fn(); const resetButtonStyle = jest.fn();
const wrapper = shallowMount(modal, { const wrapper = shallowMount(modal, {
@ -118,13 +161,17 @@ describe("modal.vue", () => {
it("Should have a disabled footer button when the form is invalid", async () => { it("Should have a disabled footer button when the form is invalid", async () => {
// Arrange / Act // Arrange / Act
useForm.mockReturnValue({ const fakeMeta = {
validate: mockValidate(false), touched: true,
}); dirty: true,
valid: true,
validated: true,
};
useIsFormTouched.mockReturnValue(true); useForm.mockReturnValue({
useIsFormDirty.mockReturnValue(true); meta: mockMeta(fakeMeta),
useIsFormValid.mockReturnValue(false); validate: mockValidate(true),
});
const resetButtonStyle = jest.fn(); const resetButtonStyle = jest.fn();
const wrapper = shallowMount(modal, { const wrapper = shallowMount(modal, {
@ -142,12 +189,17 @@ describe("modal.vue", () => {
it("Should call bootstrap Modal method 'show' when calling 'openModal'", async () => { it("Should call bootstrap Modal method 'show' when calling 'openModal'", async () => {
// Arrange // Arrange
useForm.mockReturnValue({ const fakeMeta = {
validate: mockValidate(false), touched: true,
}); dirty: true,
valid: true,
validated: true,
};
useIsFormDirty.mockReturnValue(true); useForm.mockReturnValue({
useIsFormValid.mockReturnValue(true); meta: mockMeta(fakeMeta),
validate: mockValidate(true),
});
const showMock = jest.spyOn(Modal.prototype, "show"); const showMock = jest.spyOn(Modal.prototype, "show");
@ -170,13 +222,17 @@ describe("modal.vue", () => {
it("Should call bootstrap Modal method 'hide' when calling 'closeModal'", async () => { it("Should call bootstrap Modal method 'hide' when calling 'closeModal'", async () => {
// Arrange // Arrange
const fakeMeta = {
touched: true,
dirty: true,
valid: true,
validated: true,
};
useForm.mockReturnValue({ useForm.mockReturnValue({
validate: mockValidate(false), meta: mockMeta(fakeMeta),
validate: mockValidate(true),
}); });
useIsFormDirty.mockReturnValue(true);
useIsFormValid.mockReturnValue(true);
const hideMock = jest.spyOn(Modal.prototype, "hide"); const hideMock = jest.spyOn(Modal.prototype, "hide");
const resetButtonStyle = jest.fn(); const resetButtonStyle = jest.fn();

View file

@ -43,7 +43,7 @@
<script> <script>
import buttonMain from "@/ux-components/button-main/button-main"; import buttonMain from "@/ux-components/button-main/button-main";
import { Modal } from "bootstrap"; import { Modal } from "bootstrap";
import { useForm, useIsFormTouched, useIsFormDirty, useIsFormValid } from "vee-validate"; import { useForm } from "vee-validate";
export default { export default {
name: "modal", name: "modal",
@ -60,22 +60,18 @@ export default {
setup() { setup() {
const modalId = `modal-${crypto.randomUUID()}`; const modalId = `modal-${crypto.randomUUID()}`;
const form = useForm(); const { meta, validate, resetForm } = useForm();
const isFormTouched = useIsFormTouched();
const isFormDirty = useIsFormDirty();
const isFormValid = useIsFormValid();
return { return {
modalId, modalId,
form, meta,
isFormTouched, validate,
isFormDirty, resetForm,
isFormValid,
}; };
}, },
methods: { methods: {
async validateAndEmit() { async validateAndEmit() {
const validationResult = await this.form.validate(); const validationResult = await this.validate();
if (validationResult.valid) { if (validationResult.valid) {
this.$emit("footer-button-event"); this.$emit("footer-button-event");
} else { } else {
@ -103,10 +99,10 @@ export default {
}, },
computed: { computed: {
isFooterButtonDisabled() { isFooterButtonDisabled() {
if (!this.isFormTouched) { if (!this.meta.touched) {
return !this.isFormValid; return !this.meta.valid;
} }
return !this.isFormDirty || !this.isFormValid; return !this.meta.dirty || !this.meta.valid;
}, },
}, },
components: { components: {

View file

@ -1,11 +1,19 @@
<template> <template>
<div class="textbox-question" :class="(errors && errors.length) || hasError ? 'has-error' : ''"> <div
class="textbox-question"
:class="[
(errors && errors.length) || hasError ? 'has-error' : '',
hideInput ? 'hide-input' : '',
]">
<label <label
v-if="displayQuestionText" v-if="displayQuestionText"
:for="inputId" :for="inputId"
:aria-label="questionText" :aria-label="questionText"
class="form-label" class="form-label"
:class="[questionAlignment === 'center' ? 'text-center w-100 mb-5' : '']" :class="[
questionAlignment === 'center' ? 'text-center w-100 mb-5' : '',
hideInput ? 'hide-input' : '',
]"
v-html="questionText"></label> v-html="questionText"></label>
<div class="input-wrapper" :class="[includeSearchIcon ? 'has-search-icon' : '']"> <div class="input-wrapper" :class="[includeSearchIcon ? 'has-search-icon' : '']">
<input <input
@ -25,6 +33,7 @@
hasIcon ? 'has-icon' : '', hasIcon ? 'has-icon' : '',
iconRight ? 'icon-right' : '', iconRight ? 'icon-right' : '',
cornerStyle === 'rounded' ? 'rounded-pill' : '', cornerStyle === 'rounded' ? 'rounded-pill' : '',
hideInput ? 'hide-input' : '',
]" ]"
:validationRules="validationRules" :validationRules="validationRules"
@change="handleChange" @change="handleChange"
@ -34,7 +43,12 @@
<button v-if="includeSearchIcon" type="submit" aria-label="Search button" /> <button v-if="includeSearchIcon" type="submit" aria-label="Search button" />
</div> </div>
<div v-show="errorMessage" class="row my-1 form-test-error"> <div v-show="errorMessage" class="row my-1 form-test-error">
<span class="d-inline-flex small mt-0" role="alert">{{ errorMessage }}</span> <span
class="d-inline-flex small mt-0"
role="alert"
:class="[centerErrorMessage ? 'center-error-message' : '']"
>{{ errorMessage }}</span
>
</div> </div>
</div> </div>
</template> </template>
@ -74,6 +88,8 @@ export default {
questionAlignment: String, // Left or center. Left is default. questionAlignment: String, // Left or center. Left is default.
cornerStyle: String, // Rounded or square. Square is default. cornerStyle: String, // Rounded or square. Square is default.
includeSearchIcon: Boolean, includeSearchIcon: Boolean,
hideInput: Boolean,
centerErrorMessage: Boolean,
}, },
setup(props) { setup(props) {
const inputId = !props.customInputId ? `input-${crypto.randomUUID()}` : props.customInputId; const inputId = !props.customInputId ? `input-${crypto.randomUUID()}` : props.customInputId;
@ -142,6 +158,12 @@ export default {
<style lang="scss"> <style lang="scss">
.textbox-question { .textbox-question {
.hide-input {
display: none;
}
.center-error-message {
justify-content: center !important;
}
label { label {
color: $black; color: $black;
font-weight: 500; font-weight: 500;

View file

@ -26,6 +26,8 @@
:ref="modalName" :ref="modalName"
:headerText="modalHeaderText" :headerText="modalHeaderText"
:footerButtonText="modalFooterText" :footerButtonText="modalFooterText"
:onModalOpenedCallback="onModalOpened"
:onModalClosedCallback="onModalClosed"
@footer-button-event="setMobileLocation"> @footer-button-event="setMobileLocation">
<addressQuestions <addressQuestions
ref="addressQuestions" ref="addressQuestions"
@ -56,7 +58,6 @@ import modal from "@/digital-components/modal/modal";
import alert from "@/ux-components/alert/alert"; import alert from "@/ux-components/alert/alert";
import addressQuestions from "@/layouts/address-lookup/customer-questions/address-questions/address-questions"; 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 vehicleProtectedQuestion from "@/layouts/service-location/mobile-location-modal-questions/vehicle-protected-question/vehicle-protected-question";
import store from "@/store";
// Helpers // Helpers
import { deepClone } from "@/layouts/service-location/helpers/object-cloning-helper/object-cloning-helper"; import { deepClone } from "@/layouts/service-location/helpers/object-cloning-helper/object-cloning-helper";
@ -138,7 +139,6 @@ export default {
modalFooterText() { modalFooterText() {
return this.getCmsContent(this.modalWidgetName, "FooterText"); return this.getCmsContent(this.modalWidgetName, "FooterText");
}, },
addressModel: { addressModel: {
get: function () { get: function () {
return this.modelValue.addressQuestions; return this.modelValue.addressQuestions;
@ -152,12 +152,20 @@ export default {
closeModal() { closeModal() {
this.$refs[this.modalName].closeModal(); this.$refs[this.modalName].closeModal();
}, },
onModalOpened() {
this.internalModel = deepClone(this.modelValue);
},
onModalClosed() {
this.internalModel = deepClone(this.modelValue);
},
resetComponent(updatedServiceZipCodeInfo) { resetComponent(updatedServiceZipCodeInfo) {
// Reset the validation form, setting the initial values // Reset the validation form, setting the initial values
// for the state and zipCode to those that were entered // for the state and zipCode to those that were entered
// on the service-zip-modal-question component // on the service-zip-modal-question component
this.$refs[this.modalName].form.resetForm({ this.$refs[this.modalName].resetForm({
values: { values: {
autocomplete: updatedServiceZipCodeInfo.streetAddress,
city: updatedServiceZipCodeInfo.city,
state: updatedServiceZipCodeInfo.state, state: updatedServiceZipCodeInfo.state,
zipCode: updatedServiceZipCodeInfo.zipCode, zipCode: updatedServiceZipCodeInfo.zipCode,
isVehicleProtected: updatedServiceZipCodeInfo.isVehicleProtected, isVehicleProtected: updatedServiceZipCodeInfo.isVehicleProtected,
@ -182,12 +190,13 @@ export default {
const mobileFeePart = await getPricedMobileFeePart(serviceZipCode); const mobileFeePart = await getPricedMobileFeePart(serviceZipCode);
// emit it to parent // emit additional data to parent
this.$emit("updated-mobile-fee-part", mobileFeePart); this.$emit("updated-mobile-fee-part", mobileFeePart);
this.$emit("updated-contains-military-base", zipCodeData.containsMilitaryBase);
// Update the page level model // Update the page level model
this.$emit("update:modelValue", this.internalModel); this.$emit("update:modelValue", this.internalModel);
this.$emit("updated-contains-military-base", zipCodeData.containsMilitaryBase);
this.closeModal(); this.closeModal();
} }
}, },
@ -198,6 +207,8 @@ export default {
this.internalModel = deepClone(newValue); this.internalModel = deepClone(newValue);
this.resetComponent({ this.resetComponent({
streetAddress: newValue.addressQuestions.streetAddress,
city: newValue.addressQuestions.city,
state: newValue.addressQuestions.state, state: newValue.addressQuestions.state,
zipCode: newValue.addressQuestions.zipCode, zipCode: newValue.addressQuestions.zipCode,
isVehicleProtected: newValue.isVehicleProtected, isVehicleProtected: newValue.isVehicleProtected,

View file

@ -23,8 +23,13 @@
@updated-mobile-fee-part="setMobileFeePart" @updated-mobile-fee-part="setMobileFeePart"
ref="mobileLocationModalQuestions" ref="mobileLocationModalQuestions"
linkWidgetName="MobileLocationLinkWidget" linkWidgetName="MobileLocationLinkWidget"
modalWidgetName="MobileLocationModalWidget" modalWidgetName="MobileLocationModalWidget" />
@updated-contains-military-base="setContainsMilitaryBase" /> <textboxQuestion
ref="mobileLocationQuestionsError"
v-model="mobileLocationValidationField"
validationRules="mobile-location-required"
hideInput
centerErrorMessage />
<funnel-footer <funnel-footer
cmsWidgetName="FunnelFooterWidget" cmsWidgetName="FunnelFooterWidget"
ref="funnelFooter" ref="funnelFooter"
@ -44,7 +49,8 @@ import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import funnelFooter from "@/fmg-components/funnel-footer/funnel-footer"; import funnelFooter from "@/fmg-components/funnel-footer/funnel-footer";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header"; import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue"; import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import { Form } from "vee-validate"; import { Form, defineRule } from "vee-validate";
import textboxQuestion from "@/digital-components/textbox-question/textbox-question";
// Supporting files // Supporting files
import baseMixin from "@/mixins/base-mixin.js"; import baseMixin from "@/mixins/base-mixin.js";
@ -53,6 +59,10 @@ import { settleAllPromises } from "@/helpers/layout-helper";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper"; import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import { getPricedMobileFeePart } from "@/layouts/service-location/helpers/service-location-helper/service-location-helper"; import { getPricedMobileFeePart } from "@/layouts/service-location/helpers/service-location-helper/service-location-helper";
import store from "@/store"; import store from "@/store";
import { errorMessages } from "@/constants/error-messages";
import { required } from "@/helpers/validation-rules";
defineRule("mobile-location-required", required(errorMessages.MOBILE_LOCATION_REQUIRED));
export default { export default {
name: "service-location", name: "service-location",
@ -68,6 +78,7 @@ export default {
isZipServiceableInShop: null, isZipServiceableInShop: null,
mobileFeePart: null, mobileFeePart: null,
zipContainsMilitaryBase: false, zipContainsMilitaryBase: false,
mobileLocationValidationField: null,
}; };
}, },
async beforeRouteEnter(to, from, next) { async beforeRouteEnter(to, from, next) {
@ -141,6 +152,8 @@ export default {
this.state = newValue.addressQuestions.state; this.state = newValue.addressQuestions.state;
this.zipCode = newValue.addressQuestions.zipCode; this.zipCode = newValue.addressQuestions.zipCode;
this.isVehicleProtected = newValue.isVehicleProtected; this.isVehicleProtected = newValue.isVehicleProtected;
this.mobileLocationValidationField = "isValid";
}, },
}, },
showMilitaryZipAlert() { showMilitaryZipAlert() {
@ -190,6 +203,12 @@ export default {
this.isVehicleProtected = null; this.isVehicleProtected = null;
}, },
setServiceZipCodeModalMeta(meta) {
this.serviceZipCodeMeta = meta;
},
setMobileLocationModalMeta(meta) {
this.mobileLocationMeta = meta;
},
backButtonAction() { backButtonAction() {
this.$router.navigateWithoutSaving(this.navigationScenarios.CLICKED_BACK, this.$route); this.$router.navigateWithoutSaving(this.navigationScenarios.CLICKED_BACK, this.$route);
}, },
@ -206,6 +225,7 @@ export default {
funnelSubHeader, funnelSubHeader,
Form, Form,
loadingModal, loadingModal,
textboxQuestion,
}, },
}; };
</script> </script>

View file

@ -38,7 +38,7 @@ 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 serviceZipQuestion from "@/layouts/service-location/service-zip-modal-question/service-zip-question/service-zip-question";
import modal from "@/digital-components/modal/modal"; import modal from "@/digital-components/modal/modal";
import alert from "@/ux-components/alert/alert"; import alert from "@/ux-components/alert/alert";
import store from "@/store";
import { getPricedMobileFeePart } from "@/layouts/service-location/helpers/service-location-helper/service-location-helper"; import { getPricedMobileFeePart } from "@/layouts/service-location/helpers/service-location-helper/service-location-helper";
export default { export default {
@ -96,49 +96,39 @@ export default {
resetAlerts() { resetAlerts() {
this.displayInvalidZipAlert = false; this.displayInvalidZipAlert = false;
}, },
resetsOnZipInput() { resetsOnZipInput() {
this.resetAlerts(); this.resetAlerts();
}, },
focusOnZipInput() { focusOnZipInput() {
const input = document.getElementById(this.serviceZipCodeTextInputId); const input = document.getElementById(this.serviceZipCodeTextInputId);
input?.focus(); input?.focus();
}, },
copyModel(modelToCopy) { copyModel(modelToCopy) {
return { return {
state: modelToCopy.state, state: modelToCopy.state,
zipCode: modelToCopy.zipCode, zipCode: modelToCopy.zipCode,
}; };
}, },
openModal() { openModal() {
this.$refs[this.modalName].openModal(); this.$refs[this.modalName].openModal();
}, },
closeModal() { closeModal() {
this.$refs[this.modalName].closeModal(); this.$refs[this.modalName].closeModal();
}, },
resetModalButtonStyle() { resetModalButtonStyle() {
this.$refs[this.modalName].resetButtonStyle(); this.$refs[this.modalName].resetButtonStyle();
}, },
onInputIdAssigned(inputId) { onInputIdAssigned(inputId) {
this.serviceZipCodeTextInputId = inputId; this.serviceZipCodeTextInputId = inputId;
}, },
onModalOpened() { onModalOpened() {
this.internalModel.zipCode = this.modelValue.zipCode; this.internalModel.zipCode = this.modelValue.zipCode;
this.focusOnZipInput(); this.focusOnZipInput();
}, },
onModalClosed() { onModalClosed() {
this.internalModel.zipCode = this.modelValue.zipCode; this.internalModel.zipCode = this.modelValue.zipCode;
this.resetsOnZipInput(); this.resetsOnZipInput();
}, },
async setZipCode() { async setZipCode() {
if (this.internalModel.zipCode !== this.modelValue.zipCode) { if (this.internalModel.zipCode !== this.modelValue.zipCode) {
this.resetAlerts(); this.resetAlerts();