Merge branch 'develop' into feature/CSR-108

This commit is contained in:
Adam Caouette 2022-06-30 13:49:47 -04:00
commit 56c26077b5
23 changed files with 593 additions and 496 deletions

View file

@ -10,6 +10,7 @@ describe("buttonQuestion.vue", () => {
const wrapper = shallowMount(buttonQuestion, {
propsData: {
isOverflowScrollable: true,
groupName: "group-name"
}
});
@ -25,6 +26,7 @@ describe("buttonQuestion.vue", () => {
const wrapper = shallowMount(buttonQuestion, {
propsData: {
buttonType: "listCard",
groupName: "group-name"
}
});
// Assert
@ -39,6 +41,7 @@ describe("buttonQuestion.vue", () => {
const wrapper = shallowMount(buttonQuestion, {
propsData: {
buttonType: "listButtonHorizontal",
groupName: "group-name"
}
});
// Assert
@ -53,6 +56,7 @@ describe("buttonQuestion.vue", () => {
const wrapper = shallowMount(buttonQuestion, {
propsData: {
buttonType: "radio",
groupName: "group-name"
}
});
// Assert
@ -77,7 +81,8 @@ describe("buttonQuestion.vue", () => {
// Act
const localThis = {
isWide: false,
answers: ['a', 'b']
answers: ['a', 'b'],
groupName: "group-name"
}
expect(buttonQuestion.computed.getColLength.call(localThis)).toBe("");
@ -109,7 +114,7 @@ describe("buttonQuestion.vue", () => {
describe("buttonQuestion.vue", () => {
it("Should trigger event modelValue change to new value on when radio button selected", async () => {
// Act
const wrapper = shallowMount(buttonQuestion, setupMocks({}));
const wrapper = shallowMount(buttonQuestion, setupMocks({propsData: {groupName: "group-name"}}));
await wrapper.setProps({
answers: ["2022", "2021", "2020"],
isMultiSelect: false,
@ -129,6 +134,7 @@ describe("buttonQuestion.vue", () => {
propsData: {
modelValue: ["2022", "2021", "2020"],
isMultiSelect: true,
groupName: "group-name"
}
}));
const val = { checkValue: true, value: "2019", }
@ -144,7 +150,8 @@ describe("buttonQuestion.vue", () => {
const wrapper = shallowMount(buttonQuestion, setupMocks({
propsData: {
isMultiSelect: true,
modelValue: ['a', 'b']
modelValue: ['a', 'b'],
groupName: "group-name"
}
}));
const val = { checkValue: true, value: "2021", }
@ -161,7 +168,8 @@ describe("buttonQuestion.vue", () => {
const wrapper = shallowMount(buttonQuestion, setupMocks({
propsData: {
isMultiSelect: true,
modelValue: ['a', 'b']
modelValue: ['a', 'b'],
groupName: "group-name"
}
}));
@ -173,24 +181,6 @@ describe("buttonQuestion.vue", () => {
});
});
describe("buttonQuestion.vue", () => {
it("Should do nothing to this.selectedValues if this.selectedValues is not an array", () => {
// Act
const wrapper = shallowMount(buttonQuestion, setupMocks({
propsData: {
isMultiSelect: true,
modelValue: 'a',
}
}));
const val = { checkValue: true, value: "c", }
wrapper.vm.handleCheckedChanged(val);
// Assert
expect(wrapper.vm.selectedValues).toEqual("a");
});
});
function setupMocks(mountOptionsMockData = {}) {
const defaultMountOptions = { route: { query: { fmgPage: 'page-name' } } };
const baseMountOptions = getMountOptions(Object.assign(defaultMountOptions, mountOptionsMockData));

View file

@ -1,27 +1,26 @@
<!-- 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" class="question-text d-flex">
<div v-if="questionText && answers && answers.length > 0" class="question-text d-flex">
<span class="fs-5 fw-bold w-100">{{ questionText }}</span>
</div>
<div class="w-100 d-flex justify-content-center">
<fieldset class="w-100" :aria-required=isRequired :class="getFieldSetClasses" :role="isMultiSelect ? 'group' : 'radiogroup'" :aria-labelledby="groupName">
<legend class="sr-only" :data-focus-target="groupName" :id="groupName" tabindex="-1">
<fieldset class="w-100" :aria-required=isRequired :class="getFieldSetClasses" :role="isMultiSelect ? 'group' : 'radiogroup'" :aria-labelledby="formattedGroupName">
<legend class="sr-only" :data-focus-target="formattedGroupName" :id="formattedGroupName" tabindex="-1">
{{(isMultiSelect && answers && answers.length > 1) ? 'Select one or more options below.' : 'Select an option below.' }}
</legend>
<div :class="getComponentWrapperClasses">
<div :class="getComponentLoopWrapperClasses">
<div :class="getComponentWrapperClasses" v-for="answer in answers" :key="answer.Name ? answer.Name : answer">
<component
:is="buttonType"
v-for="answer in answers"
:key="answer.Name ? answer.Name : answer"
@isCheckedChanged="handleCheckedChanged"
:buttonID="answer.Name ? groupName + '-' + answer.Name : groupName + '-' + answer"
:buttonID="answer.Name ? formattedGroupName + '-' + answer.Name : formattedGroupName + '-' + answer"
:value="getValues(answer)"
:buttonLabel="answer.Text ? answer.Text : answer"
:buttonLabelSubCopy="answer.SubText"
:textPosition="textPosition"
:isMultiSelect="isMultiSelect"
:groupName="groupName"
:groupName="formattedGroupName"
:selectingInitiatesLoad="selectingInitiatesLoad"
:loaderColor="loaderColor"
:loaderPosition="loaderPosition"
@ -38,11 +37,18 @@
:class="[suppressError ? 'alertError' : '']"
:clearOnUnmount="clearOnUnmount"
/>
<!-- For nested questions -->
<transition name="fade" mode="out-in">
<div v-if="typeof selectedValues == 'string' && selectedValues == answer.Name">
<slot></slot>
</div>
</transition>
</div>
</div>
</fieldset>
</div>
<div class="row form-test-error mt-1">
<error-message :name="groupName" v-if="!suppressError"></error-message>
<error-message :name="formattedGroupName" v-if="!suppressError"></error-message>
</div>
</div>
</template>
@ -81,22 +87,31 @@ export default {
isRequired: Boolean,
isOverflowScrollable: Boolean,
isWide: Boolean,
modelValue: Array,
modelValue: [Array, String],
validationRules: String,
suppressError: Boolean,
useTextForValue: Boolean,
clearOnUnmount: {
type: Boolean,
default: true
}
},
},
computed: {
getFieldSetClasses() {
return this.isOverflowScrollable
? "container-fluid overflow-scroll position-absolute px-5 pt-1 py-0"
: "";
formattedGroupName() {
return this.groupName.replace(" ", "-");
},
getComponentWrapperClasses() {
getFieldSetClasses() {
if (this.isOverflowScrollable) {
return "container-fluid overflow-scroll position-absolute px-5 pt-1 py-0";
}
else if (this.buttonType == "listCard") {
return "w-100";
}
else {
return "";
}
},
getComponentLoopWrapperClasses() {
let classes;
switch (this.buttonType) {
case "listButton":
@ -106,7 +121,7 @@ export default {
classes = "d-flex flex-row p-0";
break;
case 'listCard':
classes = 'row justify-content-center g-2'
classes = "row g-2 justify-content-center";
break;
case 'radio':
classes = 'ui-radio d-flex'
@ -114,11 +129,22 @@ export default {
}
return classes;
},
getComponentWrapperClasses() {
let classes = "";
classes += this.isWide ? "col-12" : "col";
if (this.buttonType == "radio") {
classes += " radio-button-container";
}
return classes;
},
getColLength(){
if(this.isWide) {
return "12"
} else {
return this.answers.length < 3 ? '' : '-4';
return "";
}
},
selectedValues: {
@ -135,6 +161,7 @@ export default {
if (this.useTextForValue){
return answer.Text
}
return answer.Name ? answer.Name : answer;
},
handleCheckedChanged(val) {
@ -146,7 +173,12 @@ export default {
val.checkValue ? newSelectedValues.push(val.value) : newSelectedValues.splice(newSelectedValues.indexOf(val.value), 1);
this.selectedValues = newSelectedValues;
}
else {
this.selectedValues = val.value;
}
}
this.$emit("isCheckedChanged", val);
},
},
components: {
@ -172,6 +204,12 @@ export default {
}
.button-question {
color: $black;
.radio-button-container {
&:not(:last-child) {
padding-bottom: map-get($spacers, 2);
}
}
}
.question-text {
margin-top: 1.5rem;

View file

@ -15,7 +15,7 @@ const maska = jest.fn();
describe("textboxQuestion.vue", () => {
it("Should render a text input", async () => {
// Arrange
// Arrange
const wrapper = shallowMount(textboxQuestion, {
global: {
directives: {
@ -53,35 +53,7 @@ describe("textboxQuestion.vue", () => {
expect(label.text()).toContain(questionText);
});
it("Should render the 'questionText' data value with '&NoBreak;' after the first character of each word in the label text when disableAutoFill is true.", async () => {
// Arrange
const wrapper = shallowMount(textboxQuestion, {
global: {
directives: {
maska: maska,
}
},
propsData: {
disableAutoFill: true,
},
mixins: [mockMixin]
});
// Mock CMS content ...
// Trust me, the below instance of the string "Question Text" actually has the &NoBreak; in it. You just can't see it
// Don't believe me? Copy and paste it into Google. Then inspect the search field element in Dev Tools,
// you will see "Q&NoBreak;uestion T&NoBreak;ext"
const expectedQuestionText = "Question Text";
// Act
const label = wrapper.find("label");
// Assert
expect(label.text()).toContain(expectedQuestionText);
});
it("Should return input id as the id of the input field", async () => {
// Arrange
const wrapper = shallowMount(textboxQuestion, {
@ -103,7 +75,7 @@ describe("textboxQuestion.vue", () => {
expect(input.attributes().id).toEqual("input ID");
});
it("Should render the 'questionText' data value as the aria-label attribute.", async () => {
// Arrange
@ -193,6 +165,6 @@ describe("textboxQuestion.vue", () => {
// Assert
expect(wrapper.vm.handleChange).toHaveBeenCalled;
});
});
});

View file

@ -1,7 +1,6 @@
<template>
<div class="textbox-question" :class="(errors && errors.length) || hasError ? 'has-error' : ''">
<label :for="inputId" :aria-label="questionText" class="form-label" v-html="labelText"></label>
<!-- See https://stackoverflow.com/a/30976223 for information about "do-not-autofill" -->
<input
v-model.trim="value"
v-maska="mask"
@ -14,7 +13,6 @@
:aria-disabled="isDisabled"
:disabled="isDisabled"
:aria-required="isRequired"
autocomplete="do-not-autofill"
:class="[hasIcon ? 'has-icon' : '', iconRight ? 'icon-right' : '']"
:validationRules="validationRules"
@change="handleChange"
@ -46,7 +44,6 @@ export default {
inputId: String,
isDisabled: Boolean,
isRequired: Boolean,
disableAutoFill: Boolean,
hasIcon: Boolean, // If input has an icon
iconRight: Boolean, // Place icon on right side of text input, otherwise default is left if hasIcon prop is used
hasError: Boolean,
@ -132,7 +129,7 @@ export default {
const result = await validate(newValue, this.validationRules); // do a test validation check, without triggering full validation
if (result.valid) {
this.handleChange(newValue); // trigger full validation on this field only
}
}
},
},
};

View file

@ -9,7 +9,7 @@
<loadingModal ref="loadingModal"/>
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
<vehicleBanner cmsWidgetName="VehicleBannerWidget" ref="vehicleBanner" :displayGenericVehicleImage=false />
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" ref="funnelSubHeader" />
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" ref="funnelSubHeader" />
<div class="fade-on-route-transition sub-container make-tall">
<customerQuestions ref="customerQuestions" v-model="customerQuestions" />
<alert ref="alertVinNotFound" v-if="displayVinNotFoundAlert"
@ -24,14 +24,14 @@
:manualCopy="AlertMatchedDifferentVehicleBody"
alertClass="alert-warning"
v-bind:isDismissible="false"
/>
/>
<alert ref="alertNonServiceableZip" v-if="displayNonServiceableZipAlert"
class="mb-4"
alertClass="alert-danger"
:manualHeadline="AlertNonServiceableZipHeader"
:manualCopy="AlertNonServiceableZipBody"
v-bind:isDismissible="false"
/>
/>
<alert ref="alertVinLookupsByHomeAddressNotAllowed" v-if="displayVinLookupByHomeAddressNotAllowedAlert"
class="mb-4"
cmsWidgetName="AlertVinLookupsByHomeAddressNotAllowedWidget"
@ -42,11 +42,11 @@
<div class="service-zip-field" v-if="showServiceZipField" aria-live="polite">
<div class="row mb-4">
<div class="col">
<textboxQuestion cmsWidgetName="ServiceZipQuestionWidget" v-model="serviceZipCode" ref="serviceZip" inputId="7add1b26df344f2caf1678de5797803f" aria-haspopup="" mask="#####" disableAutoFill validationRules="service-zip-required|service-zip-format" />
<textboxQuestion cmsWidgetName="ServiceZipQuestionWidget" v-model="serviceZipCode" ref="serviceZip" inputId="7add1b26df344f2caf1678de5797803f" aria-haspopup="" mask="#####" validationRules="service-zip-required|service-zip-format" />
</div>
</div>
</div>
</transition>
</transition>
<funnel-footer
cmsWidgetName="FunnelFooterWidget"
ref="funnelFooter"
@ -55,7 +55,7 @@
@back-clicked="backButtonAction"
:isForwardActionDisabled="!meta.valid"
/>
</div>
</div>
</div>
</Form>
</template>
@ -122,14 +122,14 @@ export default {
firstName: this.getRegistrationFirstNameFromStore(),
lastName: this.getRegistrationLastNameFromStore(),
emailAddress: this.getEmailFromStore(),
},
},
serviceZipCode: this.getServiceZipFromStore(),
displayNonServiceableZipAlert: false,
displayVinNotFoundAlert: false,
displayMatchedDifferentVehicleAlert: false,
displayVinLookupByHomeAddressNotAllowedAlert: false,
previouslyEnteredCarId: "",
isSelectedGlassAvailableForVehicle: false,
isSelectedGlassAvailableForVehicle: false,
customAlertData: {},
showServiceZipField: this.getServiceZipFromStore(),
isZipServicable: false,
@ -150,7 +150,7 @@ export default {
this.$route
);
},
attachCustomEvents() {
attachCustomEvents() {
this.prependActionToMethod(this, this.forwardButtonAction, () => {
this.pushEventToGA(
this.$route.query[this.queryStrings.FMG_PAGE],
@ -177,7 +177,7 @@ export default {
},
getRegistrationLastNameFromStore() {
return store.getters.vehicle.registration.lastName;
},
},
getEmailFromStore() {
return store.getters.order.customer.emailAddress;
},
@ -189,9 +189,9 @@ export default {
// Lookup VIN(s) with the provided address
const vinLookupPromise = this.lookupVin(
this.customerQuestions.lastName,
this.customerQuestions.addressQuestions.streetAddress,
this.customerQuestions.addressQuestions.zipCode,
this.customerQuestions.lastName,
this.customerQuestions.addressQuestions.streetAddress,
this.customerQuestions.addressQuestions.zipCode,
this.customerQuestions.addressQuestions.state
);
@ -200,8 +200,8 @@ export default {
const vinLookupResponse = await vinLookupPromise;
const serviceZipValidationResponse = await serviceZipValidationPromise;
if (!vinLookupResponse.data.isStatePermissible) {
if (!vinLookupResponse.data.isStatePermissible) {
// State Restrictions forbid lookup by address
this.displayVinLookupByHomeAddressNotAllowedAlert = true;
this.$refs.funnelFooter.removeLoader();
@ -210,29 +210,29 @@ export default {
// if the neither the registration zip code or service zip code are not serviceable
this.isZipServicable = serviceZipValidationResponse.data.isServiceable;
if (!this.isZipServicable) {
if (!this.isZipServicable) {
this.displayNonServiceableZipAlert = true;
this.showServiceZipField = true;
this.$refs.funnelFooter.removeLoader();
this.$refs.funnelFooter.removeLoader();
} else if (!this.serviceZipCode) {
// if the registration zip code is servicable and nothing was entered for the service zip code
// then set the service zip code to the registration zip code
this.serviceZipCode = this.customerQuestions.addressQuestions.zipCode;
}
}
const carEntered = store.getters.vehicle;
const carsFound = vinLookupResponse.data.vinVehicles;
if (carsFound.length == 0) {
// No VINs found
// No VINs found
this.displayVinNotFoundAlert = true;
this.$refs.funnelFooter.removeLoader();
return;
} else if (carsFound.length == 1) {
return;
} else if (carsFound.length == 1) {
const carFound = carsFound[0].vehicle;
this.isCarIdDifferent = carFound.carId !== carEntered.carId;
if (this.isCarIdDifferent && carFound.carId !== this.previouslyEnteredCarId) {
if (this.isCarIdDifferent && carFound.carId !== this.previouslyEnteredCarId) {
// Display Alert
this.previouslyEnteredCarId = carFound.carId;
this.customAlertData.vehicleInfo = carFound;
@ -241,12 +241,12 @@ export default {
this.isSelectedGlassAvailableForVehicle = await isGlassAvailableForCarId(carFound.carId);
// Update button "Continue with..."
this.$refs.funnelFooter.updateButtonText(`Continue with ${carFound.year} ${carFound.make} ${carFound.model}`);
this.$refs.funnelFooter.updateButtonText(`Continue with ${carFound.year} ${carFound.make} ${carFound.model}`);
this.$refs.funnelFooter.removeLoader();
return;
}
}
if (!this.isZipServicable) {
return;
}
@ -254,21 +254,21 @@ export default {
// update data if the zip or service zip is servicable
this.updateVehicleInfo(carsFound[0].vin, carFound);
this.updateCustomerInfo(serviceZipValidationResponse.data.state);
} else if (carsFound.length > 1) {
if (!this.isZipServicable) {
return;
}
// if multiple cars were found
let matchingCars = carsFound.filter(car => car.vehicle.carId === carEntered.carId);
let matchingCars = carsFound.filter(car => car.vehicle.carId === carEntered.carId);
if (matchingCars.length === 1) {
// and one and only one of them matches the carId entered, save the vehicle info
// so we can go to the Heritage Funnel directly
const matchingCar = matchingCars[0];
this.updateVehicleInfo(matchingCar.vin, matchingCar.vehicle);
}
// update data if the zip or service zip is servicable
this.updateCustomerInfo(serviceZipValidationResponse.data.state);
}
@ -277,13 +277,13 @@ export default {
},
resetWarningsAndErrors() {
this.displayVinNotFoundAlert = false;
this.displayNonServiceableZipAlert = false;
this.displayNonServiceableZipAlert = false;
this.displayMatchedDifferentVehicleAlert = false;
this.displayVinLookupByHomeAddressNotAllowedAlert = false;
},
navigateForward(carEntered, carsFound) {
this.updateServiceLocationIfNecessary();
if (carsFound.length == 1) {
// if a different vehicle is found than the one entered and the selected glass
// is not available for that vehicle
@ -297,37 +297,37 @@ export default {
}, {}
);
} else {
// otherwise
// otherwise
this.navigateForwardWithSingleCarMatch();
}
}
} else if (carsFound.length > 1) {
// if multiple cars were found
let matchingCars = carsFound.filter(car => car.vehicle.carId === carEntered.carId);
let matchingCars = carsFound.filter(car => car.vehicle.carId === carEntered.carId);
if (matchingCars.length === 1) {
// and one and only of them matches the car id entered
const matchingCar = matchingCars[0];
const matchingCar = matchingCars[0];
this.updateVehicleInfo(matchingCar.vin, matchingCar.vehicle);
this.navigateForwardWithSingleCarMatch();
} else {
// if there are no matches or there are multiple matches, navigate to "address-vehicles" page
this.$router.navigateAfterSave(this.navigationScenarios.CONTINUING_WITH_MULTIPLE_VEHICLES, this.$route, {}, {}, carsFound);
}
}
}
},
validateZip(zip) {
return this.dispatchStoreAction(
storeActions.VALIDATE_ZIP,
storeActions.VALIDATE_ZIP,
{ zip });
},
lookupVin(lastName, streetAddress, zip, state) {
return this.dispatchStoreAction(
storeActions.LOOKUP_VIN_BY_ADDRESS,
{
licenseLastName: lastName,
licenseStreetAddress: streetAddress,
licenseZip: zip,
licenseState: state
{
licenseLastName: lastName,
licenseStreetAddress: streetAddress,
licenseZip: zip,
licenseState: state
}, false
);
},
@ -342,7 +342,7 @@ export default {
store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_URL, vehicleInfo.imageUrl);
store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_VIF_NUMBER, vehicleInfo.imageVifNumber);
store.commit(storeMutations.UPDATE_VEHICLE_IMAGE_COLOR, vehicleInfo.imageColor);
},
},
updateCustomerInfo(serviceState) {
store.commit(storeMutations.UPDATE_REGISTRATION_ADDRESS, this.customerQuestions.addressQuestions.streetAddress);
store.commit(storeMutations.UPDATE_REGISTRATION_CITY, this.customerQuestions.addressQuestions.city);
@ -350,10 +350,10 @@ export default {
store.commit(storeMutations.UPDATE_REGISTRATION_ZIP_CODE, this.customerQuestions.addressQuestions.zipCode);
store.commit(storeMutations.UPDATE_REGISTRATION_FIRST_NAME, this.customerQuestions.firstName);
store.commit(storeMutations.UPDATE_REGISTRATION_LAST_NAME, this.customerQuestions.lastName);
store.commit(storeMutations.UPDATE_SERVICE_LOCATION_ZIP_CODE, this.serviceZipCode);
store.commit(storeMutations.UPDATE_SERVICE_LOCATION_STATE, serviceState);
store.commit(storeMutations.UPDATE_SERVICE_LOCATION_ZIP_CODE, this.serviceZipCode);
store.commit(storeMutations.UPDATE_SERVICE_LOCATION_STATE, serviceState);
store.commit(storeMutations.UPDATE_CUSTOMER_EMAIL_ADDRESS, this.customerQuestions.emailAddress);
},
},
updateServiceLocationIfNecessary() {
const serviceLocation = store.getters.order.serviceLocation;
@ -373,7 +373,7 @@ export default {
},
AlertNonServiceableZipBody() {
return this.getCmsContent("AlertNonServiceableZipWidget", "BodyText");
},
},
AlertMatchedDifferentVehicleHeader() {
const text = this.getCmsContent("AlertMatchedDifferentVehicleWidget", "HeadlineText").replaceAll("{custom:glassText}", getDamageString());
return text;
@ -386,13 +386,13 @@ export default {
const content = this.getCmsContent("AlertMatchedDifferentVehicleWidget", "BodyText")
.replaceAll("{custom:glassText}", getDamageString())
.replaceAll("{custom:vinYmmFound}", vinYmmFound)
.replaceAll("{custom:vinYmmExpected}", vinYmmExpected);
.replaceAll("{custom:vinYmmExpected}", vinYmmExpected);
return content;
},
},
},
watch: {
customerQuestions: {
customerQuestions: {
handler(newValue) {
// if they modify one of the lookup fields (address, city, state, zipCode, or lastName), then modify the button text back to Get my personalized quote
this.$refs.funnelFooter.updateButtonText(this.getCmsContent("FunnelFooterWidget", "ForwardButtonText"));
@ -412,8 +412,8 @@ export default {
// if the Service Zip Code field is ever hidden, clear out it's value
if (!newValue) {
this.serviceZipCode = null;
}
},
}
},
}
},
components: {

View file

@ -223,7 +223,11 @@ export default {
handler() {
// does this vehicle match the previously selected carId?
this.isCarIdDifferent = this.selectedVehicle.vehicle.carId !== store.getters.vehicle.carId;
this.$refs.funnelFooter.updateButtonText(`Continue with ${this.selectedVehicle.vehicle.year} ${this.selectedVehicle.vehicle.make} ${this.selectedVehicle.vehicle.model}`);
if (this.isCarIdDifferent) {
this.$refs.funnelFooter.updateButtonText(`Continue with ${this.selectedVehicle.vehicle.year} ${this.selectedVehicle.vehicle.make} ${this.selectedVehicle.vehicle.model}`);
} else {
this.$refs.funnelFooter.updateButtonText(this.getCmsContent("FunnelFooterWidget", "ForwardButtonText"));
}
},
deep: true
},

View file

@ -0,0 +1,10 @@
<script>
export default {
name: "quote",
methods: {
arePagePrerequisitesValid() {
return true;
}
}
}
</script>

View file

@ -7,7 +7,7 @@
:answers="makes"
groupName="ChooseVehicleMake"
textPosition="text-start"
v-model="selectedValueAsArray"
v-model="selectedValue"
isRequired=true
/>
</template>
@ -34,14 +34,13 @@ export default {
questionText(){
return this.getCmsContent(this.cmsWidgetName, 'QuestionText');
},
selectedValueAsArray: {
selectedValue: {
get: function() {
const modelValueAsArray = this.modelValue ? [this.modelValue] : [];
return modelValueAsArray;
},
set: function(newValue) {
const newValueAsScalar = newValue && newValue.length > 0 ? newValue[0] : null;
this.$emit("update:modelValue", newValueAsScalar);
this.$emit("update:modelValue", newValue);
}
}
},

View file

@ -7,7 +7,7 @@
:answers="models"
groupName="ChooseVehicleModel"
textPosition="text-start"
v-model="selectedValueAsArray"
v-model="selectedValue"
isRequired=true
/>
</template>
@ -34,14 +34,13 @@ export default {
questionText(){
return this.getCmsContent(this.cmsWidgetName, 'QuestionText');
},
selectedValueAsArray: {
selectedValue: {
get: function() {
const modelValueAsArray = this.modelValue ? [this.modelValue] : [];
return modelValueAsArray;
},
set: function(newValue) {
const newValueAsScalar = newValue && newValue.length > 0 ? newValue[0] : null;
this.$emit("update:modelValue", newValueAsScalar);
this.$emit("update:modelValue", newValue);
}
}
},

View file

@ -69,24 +69,25 @@ describe("glass-part-question.vue", () => {
});
test("Should emit updateModelValue, and have correct attributes", async () => {
//Arrange
const { wrapper } = setupMocks(featureListData);
store.getters.pageData.mockReset();
store.getters.pageData.mockReturnValueOnce({ partsOrQuestions: [{glassName: "Stationary", glassLocation: "Rear", parts: [{ partNumber: "DB12209GTYN", color: "Green Tint"}]}] });
//Act
await wrapper.vm.$nextTick();
const listCard = await wrapper.findComponent({
name: "listCard",
name: "buttonQuestion",
});
wrapper.setValue({ selectedTint: 'Green Tint' });
await wrapper.setData({ selectedTint: 'Green Tint' });
// to trigger the computed setter
wrapper.vm.selectedPartNumber = "DB12209GTYN";
//Assert
expect(wrapper.emitted()["update:modelValue"][0]).toEqual([{ selectedTint: 'Green Tint' }]);
expect(listCard.attributes("buttonid")).toBe("Rear-Stationary-Green Tint");
expect(wrapper.emitted()["update:modelValue"][0]).toEqual([{ partNumber: "DB12209GTYN", color: "Green Tint"}]);
expect(listCard.attributes("groupname")).toBe("Rear-Stationary");
expect(listCard.attributes("isradio")).toBe("true");
expect(listCard.attributes("validationrules")).toBe("Rear-Stationary-tint-required");
});
test("ResetTintAndPartSelections, should reset data elements ", async () => {
@ -96,21 +97,101 @@ describe("glass-part-question.vue", () => {
//Act
await wrapper.vm.$nextTick();
wrapper.setData({ selectedTint: { "Rear-Stationary": 'Green Tint' } });
expect(wrapper.vm.selectedTint).toStrictEqual({ "Rear-Stationary": 'Green Tint' });
await wrapper.setData({ selectedTint: 'Green Tint', selectedPartNumber: "test" });
expect(wrapper.vm.selectedTint).toEqual('Green Tint');
expect(wrapper.vm.selectedPartNumber).toEqual('test');
await wrapper.vm.ResetTintAndPartSelections();
expect(wrapper.vm.selectedTint).toStrictEqual({});
expect(wrapper.vm.selectedTint).toEqual("Green Tint");
expect(wrapper.vm.selectedPartNumber).toEqual(null);
});
test("default is selected if only one option", async () => {
// Arrange
store.getters.pageData.mockReset();
store.getters.pageData.mockReturnValueOnce({ partsOrQuestions: [{glassName: "Stationary", glassLocation: "Rear", parts: [{ partNumber: "DB12209GTYN", color: "Green Tint"}]}] });
const { wrapper } = setupMocks(featureListData);
// Act
await wrapper.vm.$nextTick();
await wrapper.setData({ selectedTint: "Green Tint" });
// take emitted value, pass down as modelValue
// yes, yes, it's not ideal
await wrapper.setProps({ modelValue: wrapper.emitted()["update:modelValue"][0][0] })
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.vm.selectedPartNumber).toBe("DB12209GTYN");
});
test("default is not selected if more than one option", async () => {
// Arrange
store.getters.pageData.mockReset();
store.getters.pageData.mockReturnValueOnce({ partsOrQuestions: [{glassName: "Stationary", glassLocation: "Rear", parts: [{ partNumber: "DB12209GTYN", color: "Green Tint"}, { partNumber: "DB12209GTYNXXX", color: "Green Tint"}]}] });
const { wrapper } = setupMocks(featureListData);
// Act
await wrapper.vm.$nextTick();
await wrapper.setData({ selectedTint: "Green Tint" });
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.emitted()["update:modelValue"]).toBeFalsy();
expect(wrapper.vm.selectedPartNumber).toBeFalsy();
})
const partsForSelectedTintTestCases = [
["Rear", "Stationary", "Green Tint", [{ partNumber: "Glass1", color: "Green Tint"}, { partNumber: "Glass3", color: "Green Tint"}, { partNumber: "Glass4", color: "Green Tint"}, { partNumber: "Glass6", color: "Green Tint"} ]],
["Rear", "Stationary", "Blue Tint", [{ partNumber: "Glass2", color: "Blue Tint"}, { partNumber: "Glass5", color: "Blue Tint"}]],
["Rear", "Stationary", "Red Tint", [{ partNumber: "Glass7", color: "Red Tint"}]],
["Windshield", "Single", "Green Tint", [{ partNumber: "Windshield1", color: "Green Tint"}, { partNumber: "Windshield2", color: "Green Tint"}]],
["Windshield", "Single", "Blue Tint", []],
["Driver", "Quarter", "Green Tint", []]
];
test.each(partsForSelectedTintTestCases)("partsForSelectedTint returns correct parts", async (glassLocation, glassName, selectedTint, expectedResults) => {
// Arrange
store.getters.pageData.mockReset();
store.getters.pageData.mockReturnValueOnce({ partsOrQuestions: [
{
glassName: "Stationary",
glassLocation: "Rear",
parts: [
{ partNumber: "Glass1", color: "Green Tint"},
{ partNumber: "Glass2", color: "Blue Tint"},
{ partNumber: "Glass3", color: "Green Tint"},
{ partNumber: "Glass4", color: "Green Tint"},
{ partNumber: "Glass5", color: "Blue Tint"},
{ partNumber: "Glass6", color: "Green Tint"},
{ partNumber: "Glass7", color: "Red Tint"}
]
},
{
glassName: "Single",
glassLocation: "Windshield",
parts: [{ partNumber: "Windshield1", color: "Green Tint"}, { partNumber: "Windshield2", color: "Green Tint"}]
}
]});
const { wrapper } = setupMocks({
glassLocationProp: glassLocation,
glassNameProp: glassName,
colorAnswersProp: [],
});
// Act
await wrapper.setData({ selectedTint: selectedTint });
// Assert
expect(expectedResults).toEqual(wrapper.vm.partsForSelectedTint);
});
});
function setupMocks({ glassNameProp, glassLocationProp, colorAnswersProp, modelValueProp }) {
function setupMocks({ glassNameProp, glassLocationProp, colorAnswersProp, modelValueProp }) {
//Mock store
store.getters.pageData.mockReturnValueOnce({ partsOrQuestions: {}});
store.getters.pageData.mockReturnValueOnce({ partsOrQuestions: [{glassName: "Stationary", glassLocation: "Rear", parts: []}] });
store.getters.lineItems = { glassParts: {} }
const mountOptions = getMountOptions({

View file

@ -1,245 +1,230 @@
<template>
<div class="container">
<div class="row">
<p class="mb-0 color-question-text">{{ colorQuestionText }}</p>
<p class="mb-0 color-question-text">{{ colorQuestionText }}</p>
</div>
</div>
<div
class="container nested-radio"
v-for="(value, name, index) in featureListData"
:key="index"
>
<div class="row my-2">
<div class="col">
<listCard
v-model="selectedTint[name]"
:value="`${glassLocation}-${glassName}-${name}`"
:isRadio="true"
:isWide="true"
:buttonImage="
require(`@/assets/img/tints/${getTintSourceImage(
glassLocation,
name
)}`)
"
:buttonLabel="name"
altText=""
isRequired
:buttonID="`${glassLocation}-${glassName}-${name}`"
:groupName="`${glassLocation}-${glassName}`"
@isCheckedChanged="ResetTintAndPartSelections()"
:validationRules="validationRules"
/>
<div class="row form-test-error mt-1">
<error-message :name="`${glassLocation}-${glassName}`" v-if="!suppressError"></error-message>
<div class="nested-radio">
<div class="row my-2">
<div class="col">
<buttonQuestion
v-model="selectedTint"
:answers="tintSelectionOptions"
buttonType="listCard"
:isWide="true"
altText=""
isRequired
:groupName="`${glassLocation}-${glassName}`"
@isCheckedChanged="ResetTintAndPartSelections"
:validationRules="tintValidationRules"
>
<div class="row my-2" aria-live="polite">
<div class="col">
<buttonQuestion
v-model="selectedPartNumber"
buttonType="radio"
class="radioQuestion"
:questionText="glassFeatureQuestion"
:answers="featureListData[selectedTint]"
textPosition="text-start"
:loaderEnabled="false"
isRequired
:groupName="`${glassLocation}-${glassName}-${selectedTint}`"
:validationRules="partValidationRules"
/>
</div>
</div>
</buttonQuestion>
</div>
</div>
</div>
</div>
<transition name="fade" mode="out-in">
<div
v-if="
selectedTint[name] != undefined &&
selectedTint[name].buttonId ===
`${glassLocation}-${glassName}-${name}`
"
class="row my-2"
aria-live="polite"
>
<div class="col">
<buttonQuestion
v-model="selectedPart[glassLocation]"
buttonType="radio"
class="radioQuestion"
:questionText="glassFeatureQuestion"
:answers="value"
textPosition="text-start"
:loaderEnabled="false"
isRequired
:groupName="`${glassLocation}-${glassName}-${name}`"
:validationRules="validationRules"
/>
</div>
</div>
</transition>
</div>
</template>
<script>
// Components
import listCard from "@/ux-components/list-card/list-card";
import buttonQuestion from "@/common-components/button-question/button-question";
// Supporting files
import { getTintImage } from "@/constants/tint-mapper";
import { getCustomTransformValue } from "@/constants/dynamictext-mapper";
import { ErrorMessage } from 'vee-validate';
import { defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
export default {
name: "glass-part-question",
inheritAttrs: false,
data() {
return {
glassColorQuestion: "",
glassFeatureQuestion: "",
selectedTint: {}, // v-model for the list card selections
};
},
props: {
glassName: String,
glassLocation: String,
colorAnswers: Array,
modelValue: Object,
validationRules: String,
},
mounted() {
this.LoadPreselectedValues();
},
components: {
listCard,
buttonQuestion,
ErrorMessage,
},
computed: {
colorQuestionText() {
return getCustomTransformValue(
this.glassColorQuestion,
`${this.glassLocation} ${this.glassName}`
);
name: "glass-part-question",
inheritAttrs: false,
data() {
return {
glassColorQuestion: "",
glassFeatureQuestion: "",
selectedTint: "",
};
},
selectedPart: {
get: function () {
return this.modelValue;
},
set: function (newValue) {
this.$emit("update:modelValue", newValue);
},
props: {
glassName: String,
glassLocation: String,
colorAnswers: Array,
modelValue: Object,
alreadyPopulatedPartsData: Array
},
// Creates a map of the feature list data in the correct Name/Value
// format for the button-question component.
featureListData() {
const tintMapByColor = this.colorAnswers.reduce((arr, item) => {
// Create the key
const itemColor = item.ColorAnswerText;
arr[itemColor] = arr[itemColor] || [];
// Map the data to Name/Text object for ButtonQuestion
const mappedItem = item.FeatureAnswers.reduce((featureArr, item) => {
featureArr["Text"] = item.FeatureAnswerText; // Display to User
featureArr["Name"] = item.PartNumber; // Backing Value
return featureArr;
}, {});
// Add onto the final object
arr[itemColor].push(mappedItem);
return arr;
}, {});
return tintMapByColor;
mounted() {
this.LoadPreselectedValues();
},
PartDataFromApi() {
return this.$store.getters.pageData(this.$route.query.fmgPage);
},
},
methods: {
// Initialize the component data
initializeComponent(cmsContent) {
this.glassColorQuestion = cmsContent.ColorQuestionWidget.QuestionText;
this.glassFeatureQuestion = cmsContent.FeatureQuestionWidget.QuestionText;
components: {
buttonQuestion,
},
computed: {
tintValidationRules() {
const validationRuleName = `${this.glassLocation}-${this.glassName}-tint-required`;
defineRule(validationRuleName, required(errorMessages.OPTION_REQUIRED));
return validationRuleName;
},
partValidationRules() {
const validationRuleName = `${this.glassLocation}-${this.glassName}-part-required`;
defineRule(validationRuleName, required(errorMessages.OPTION_REQUIRED));
return validationRuleName;
},
colorQuestionText() {
return getCustomTransformValue(
this.glassColorQuestion,
`${this.glassLocation} ${this.glassName}`
);
},
// Gets tint images based on the glass type, and tint name.
// Returns an empty string if the src or object is undefined.
getTintSourceImage(glassLocation, tintColor) {
const tintSourceObject = getTintImage(glassLocation, tintColor);
if (tintSourceObject === undefined || tintSourceObject.src == undefined) {
return "";
}
return tintSourceObject.src;
},
// Reset selections when tint changes for the same glass to ensure proper selection.
// Also checks if only a single part is present for the tint.
ResetTintAndPartSelections() {
this.selectedTint = {};
this.selectedPart = {};
this.AutoSelectIfSinglePart();
},
// Check if only a single part is present for the tint and set the v-model if it is.
AutoSelectIfSinglePart() {
// Check if the selected tint only has a single feature
Object.keys(this.PartDataFromApi.partsOrQuestions ?? {}).forEach(
(key) => {
const currentGlassSelection =
this.PartDataFromApi.partsOrQuestions[key];
if (
currentGlassSelection.glassName === this.glassName &&
currentGlassSelection.glassLocation === this.glassLocation
) {
if (currentGlassSelection.parts.length === 1) {
this.selectedPart = {
[currentGlassSelection.glassLocation]: [
currentGlassSelection.parts[0].partNumber,
],
};
}
}
}
);
},
// Loads the preselected values from the store.
LoadPreselectedValues() {
this.$nextTick(() => {
if (this.modelValue !== undefined) {
// Populate button-question model-value if parts data already exists in VueX
const alreadyPopulatedPartsData =
this.$store.getters.lineItems.glassParts;
Object.keys(alreadyPopulatedPartsData).forEach((key) => {
const partNumber = alreadyPopulatedPartsData[key].partNumber;
const tintColor = alreadyPopulatedPartsData[key].color;
Object.keys(this.modelValue).forEach((key) => {
if (this.modelValue[key][0] === partNumber) {
this.selectedTint[tintColor] = {
buttonId: `${this.glassLocation}-${this.glassName}-${tintColor}`,
checkValue: "",
value: `${this.glassLocation}-${this.glassName}-${tintColor}`,
};
}
tintSelectionOptions() {
let tintOptions = [];
Object.keys(this.featureListData).forEach((tintOption) => {
tintOptions.push({
Name: tintOption,
Text: tintOption,
AnswerImageUrl: require(`@/assets/img/tints/${this.getTintSourceImage(
this.glassLocation,
tintOption
)}`),
});
});
});
}
});
return tintOptions;
},
selectedPartNumber: {
get() {
return this.modelValue?.partNumber;
},
set(newValue) {
this.$emit("update:modelValue", this.partsForSelectedTint.filter(part => part.partNumber == newValue)[0]);
},
},
partsForSelectedTint() {
const matchingGlass = this.PartDataFromApi.partsOrQuestions?.filter(dataForGlassLocationAndName =>
dataForGlassLocationAndName.glassName == this.glassName &&
dataForGlassLocationAndName.glassLocation == this.glassLocation);
const matchingGlassParts = matchingGlass?.length == 1 ? matchingGlass[0].parts : [];
return matchingGlassParts.filter(part => part.color == this.selectedTint) ?? [];
},
// Creates a map of the feature list data in the correct Name/Value
// format for the button-question component.
featureListData() {
const tintMapByColor = this.colorAnswers.reduce((arr, item) => {
// Create the key
const itemColor = item.ColorAnswerText;
arr[itemColor] = arr[itemColor] || [];
// Map the data to Name/Text object for ButtonQuestion
const mappedItem = item.FeatureAnswers.reduce(
(featureArr, item) => {
featureArr["Text"] = item.FeatureAnswerText; // Display to User
featureArr["Name"] = item.PartNumber; // Backing Value
return featureArr;
},
{}
);
// Add onto the final object
arr[itemColor].push(mappedItem);
return arr;
}, {});
return tintMapByColor;
},
PartDataFromApi() {
return this.$store.getters.pageData(this.$route.query.fmgPage) ?? {};
},
},
},
methods: {
// Initialize the component data
initializeComponent(cmsContent) {
this.glassColorQuestion =
cmsContent.ColorQuestionWidget.QuestionText;
this.glassFeatureQuestion =
cmsContent.FeatureQuestionWidget.QuestionText;
},
// Gets tint images based on the glass type, and tint name.
// Returns an empty string if the src or object is undefined.
getTintSourceImage(glassLocation, tintColor) {
const tintSourceObject = getTintImage(glassLocation, tintColor);
if (
tintSourceObject === undefined ||
tintSourceObject.src == undefined
) {
return "";
}
return tintSourceObject.src;
},
// Reset selections when tint changes for the same glass to ensure proper selection.
// Also checks if only a single part is present for the tint.
ResetTintAndPartSelections() {
this.selectedPartNumber = null;
this.AutoSelectIfSinglePart();
},
// Check if only a single part is present for the tint and set the v-model if it is.
AutoSelectIfSinglePart() {
if (this.partsForSelectedTint?.length == 1) {
this.selectedPartNumber = this.partsForSelectedTint[0].partNumber;
}
},
// Loads the preselected values from the store.
LoadPreselectedValues() {
this.$nextTick(() => {
if (this.modelValue !== undefined) {
// Populate button-question model-value if parts data already exists in VueX
this.selectedTint = this.alreadyPopulatedPartsData?.filter(part => part.partNumber === this.selectedPartNumber)[0].color;
}
});
},
},
watch: {
selectedTint() {
this.AutoSelectIfSinglePart();
}
}
};
</script>
<style lang="scss">
.nested-radio {
.ui-radio {
flex-direction: column;
margin: 0.25rem 0;
}
.ui-radio {
flex-direction: column;
margin: 0.25rem 0;
}
p {
font-size: 0.875rem;
}
p {
font-size: 0.875rem;
}
}
.color-question-text {
color: $black;
font-weight: $font-weight-bold;
color: $black;
font-weight: $font-weight-bold;
}
</style>

View file

@ -65,7 +65,7 @@ describe("vehicle-parts.vue", () => {
test("Set cms content called on load", async (done) => {
//Arrange
store.getters.pageData.mockReturnValue(basePartResponse);
store.getters.lineItems = { glassParts: {} }
store.getters.lineItems = { glassParts: null }
const { wrapper, apiPromise } = setupMocks(
{
@ -104,7 +104,7 @@ describe("vehicle-parts.vue", () => {
//Arrange
store.getters.pageData.mockReturnValue(basePartResponse);
store.getters.lineItems = { glassParts: {} }
store.getters.lineItems = { glassParts: null }
const { wrapper } = setupMocks({
mountOptionsMockData: {
@ -141,7 +141,7 @@ describe("vehicle-parts.vue", () => {
//Arrange
store.getters.pageData.mockReturnValueOnce(basePartResponse);
store.getters.lineItems = { glassParts: { 0: { partNumber: 'DB12209YPYNOEM'} } }
store.getters.lineItems = { glassParts: [{ partNumber: 'DB12209YPYNOEM'} ] }
const { wrapper } = setupMocks({
mountOptionsMockData: {
@ -170,14 +170,21 @@ describe("vehicle-parts.vue", () => {
await nextTick();
//Assert
expect(wrapper.vm.glassParts).toEqual({ "Rear-Stationary": { "Rear": ['DB12209YPYNOEM'] } });
expect(wrapper.vm.glassParts).toEqual({ "Rear-Stationary": {
partNumber: "DB12209YPYNOEM",
description: "heated glass, solar, 1 hole",
color: "Gray Tint Privacy",
requiresRecalibration: false,
requiresCapabilityQuestions: false,
childParts: null
}});
});
test("BackButtonAction triggers a router.navigate change", async () => {
//Arrange
store.getters.pageData.mockReturnValueOnce(basePartResponse);
store.getters.lineItems = { glassParts: {} }
store.getters.lineItems = { glassParts: null }
const { wrapper } = setupMocks({
mountOptionsMockData: {
@ -215,7 +222,7 @@ describe("vehicle-parts.vue", () => {
//Arrange
store.getters.pageData.mockReturnValueOnce(basePartResponse);
store.getters.lineItems = { glassParts: {} }
store.getters.lineItems = { glassParts: null }
store.commit = jest.fn();
@ -238,7 +245,7 @@ describe("vehicle-parts.vue", () => {
}
});
wrapper.setData({ glassParts: { "Rear-Stationary": { "Rear": ['DB12209YPYNOEM'] } } });
wrapper.setData({ glassParts: {"Rear-Stationary": { partNumber: 'DB12209GTYN'}}});
//Act
vehicleParts.beforeRouteEnter.call(
@ -285,13 +292,14 @@ function setupMocks({ pageHeaderWidgetHeaderText = {}, mountOptionsMockData = {}
settleAllPromises.mockImplementation(() => apiPromise);
fetchCmsContentForPage.mockImplementation(() => Promise.resolve());
const mountOptions = getMountOptions(mountOptionsMockData,);
const mountOptions = getMountOptions(mountOptionsMockData);
const wrapper = shallowMount(vehicleParts, mountOptions);
const partQuestionRearWrapper = wrapper.findComponent({ name: "glassPartQuestion", });
partQuestionRearWrapper.vm.initializeComponent = glassPartQuestion.methods.initializeComponent;
wrapper.vm.setCmsContent = baseMixin.methods.setCmsContent;
wrapper.vm.$refs.funnelFooter.removeLoader = jest.fn();
return { wrapper, apiPromise };
}

View file

@ -3,14 +3,13 @@
@submit="onSubmit"
@invalid-submit="onInvalidSubmit"
ref="theForm"
v-slot="{ meta }"
>
<div class="page-container-grouped-styles vehicle-parts">
<funnelHeader ref="funnelHeader" cmsWidgetName="FunnelHeaderWidget" />
<vehicleBanner ref="vehicleBanner" cmsWidgetName="VehicleBannerWidget" :displayGenericVehicleImage="false" />
<funnelSubHeader ref="funnelSubHeader" cmsWidgetName="FunnelSubHeaderWidget" />
<div class="fade-on-route-transition sub-container make-tall">
<div class="container-fluid prevent-squish my-5">
<div class="prevent-squish my-5">
<div class="row">
<div class="col">
<alert
@ -22,26 +21,22 @@
</div>
</div>
</div>
<div v-for="(item, i) in PartsForQuestions" :key="i">
<div v-for="(item, i) in PartsOrQuestions" :key="i">
<!-- Render horizontal lines if there is multi-glass (aka if i > 0) -->
<div class="container-fluid">
<hr v-if="i > 0" />
</div>
<hr v-if="i > 0" />
<glassPartQuestion
:ref="`${RefPrefix}-${item.glassLocation}-${item.glassName}`"
v-model="glassParts[item.glassLocation + '-' + item.glassName]"
:glassLocation="item.glassLocation"
:glassName="item.glassName"
:colorAnswers="item.colorAnswers"
validationRules="replace-options-required"
/>
:ref="`${RefPrefix}-${item.glassLocation}-${item.glassName}`"
v-model="glassParts[item.glassLocation + '-' + item.glassName]"
:glassLocation="item.glassLocation"
:glassName="item.glassName"
:colorAnswers="item.colorAnswers"
:alreadyPopulatedPartsData="alreadyPopulatedPartsData"
/>
</div>
<funnelFooter
cmsWidgetName="FunnelFooterWidget"
ref="funnelFooter"
:isForwardActionDisabled="!meta.valid"
:isForwardActionDisabled="isForwardActionDisabled"
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction"
/>
@ -64,12 +59,7 @@ import { settleAllPromises } from "@/helpers/layout-helper";
import { fmgPageValues } from "@/router/router-constants/fmgPage-values";
import { storeMutations } from "@/constants/store-mutations";
import store from "@/store";
import { Form, defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
// DEFINE VALIDATION RULES
defineRule("replace-options-required", required(errorMessages.OPTION_REQUIRED));
import { Form } from "vee-validate";
export default {
name: "vehicle-parts",
@ -88,12 +78,12 @@ export default {
// Glass Part Question dynamic component
Object.keys(vm.$refs)
.filter((r) => r.includes(vm.RefPrefix) && vm.$refs[r][0] !== undefined)
.forEach((c) =>
vm.$refs[c][0].initializeComponent({
ColorQuestionWidget: resultMap.cmsContent.ColorQuestionWidget,
FeatureQuestionWidget: resultMap.cmsContent.FeatureQuestionWidget,
})
.filter((r) => r.includes(vm.RefPrefix) && vm.$refs[r][0] !== undefined)
.forEach((c) =>
vm.$refs[c][0].initializeComponent({
ColorQuestionWidget: resultMap.cmsContent.ColorQuestionWidget,
FeatureQuestionWidget: resultMap.cmsContent.FeatureQuestionWidget,
})
);
});
},
@ -101,6 +91,7 @@ data() {
return {
glassParts: {},
alertWidgetData: Object,
alreadyPopulatedPartsData: {}
};
},
components: {
@ -113,7 +104,24 @@ components: {
alert,
},
computed: {
PartsForQuestions() {
isForwardActionDisabled() {
return Object.keys(this.matchedParts).length !== this.PartsFromApi.partsOrQuestions.length;
},
matchedParts() {
const matchedParts = [];
// Match them to the parts from the API.
this.PartsFromApi.partsOrQuestions.forEach(part => {
const selectedPartForGlassLocationAndName = this.glassParts[`${part.glassLocation}-${part.glassName}`];
const selectedPartData = part.parts.filter(part => selectedPartForGlassLocationAndName && part.partNumber == selectedPartForGlassLocationAndName?.partNumber)[0];
if (selectedPartData)
matchedParts.push(selectedPartData);
})
return matchedParts;
},
PartsOrQuestions() {
const partsData = this.PartsFromApi;
// Map API result data, to vehicle-parts data structure
@ -165,40 +173,14 @@ methods: {
this.$router.navigate(this.navigationScenarios.CLICKED_BACK, this.$route);
},
forwardButtonAction() {
const selectedGlassPartNumbers = [];
const matchedParts = [];
// Compile all selected parts from the page.
for (let [key, value] of Object.entries(this.glassParts)) {
for (let [glassKey, glassValue] of Object.entries(value)) {
selectedGlassPartNumbers.push(glassValue[0]);
}
}
// Match them to the parts from the API.
for (let [key, value] of Object.entries(
this.PartsFromApi.partsOrQuestions
)) {
for (let [partKey, partValue] of Object.entries(value.parts)) {
const currentPart =
this.PartsFromApi.partsOrQuestions[key].parts[partKey];
const isMatched = selectedGlassPartNumbers.some(
(p) => p === currentPart.partNumber
);
if (isMatched) {
matchedParts.push(currentPart);
}
}
}
// If no parts could be matched, throw an error.
if (matchedParts.length === 0) {
if (this.isForwardActionDisabled) {
this.$refs.funnelFooter.removeLoader();
throw new Error("Could not match any parts to the selected parts");
}
// Save parts to the store.
store.commit(storeMutations.UPDATE_GLASS_PARTS, matchedParts);
store.commit(storeMutations.UPDATE_GLASS_PARTS, this.matchedParts);
// Navigate to the next page.
this.$router.navigateAfterSave(
@ -212,25 +194,14 @@ methods: {
},
LoadInitialPartsData() {
const partsData = this.PartsFromApi;
const alreadyPopulatedPartsData =
this.$store.getters.lineItems.glassParts === null
? {}
: this.$store.getters.lineItems.glassParts;
partsData.partsOrQuestions.map((g) => {
// If the part is already populated, use the value from the store and populate the v-model.
Object.keys(alreadyPopulatedPartsData).forEach((key) => {
const partNumber = alreadyPopulatedPartsData[key].partNumber;
g.parts.forEach((p) => {
if (p.partNumber === partNumber) {
this.glassParts[g.glassLocation + "-" + g.glassName] = {
[g.glassLocation]: [partNumber],
};
}
});
});
});
this.alreadyPopulatedPartsData = this.$store.getters.lineItems.glassParts ?? [];
const savedPartNumbers = this.alreadyPopulatedPartsData.map(part => part.partNumber)
this.PartsFromApi.partsOrQuestions.forEach(glass => {
const savedPart = glass.parts.filter(part => savedPartNumbers.includes(part.partNumber))[0];
if (savedPart) {
this.glassParts[`${glass.glassLocation}-${glass.glassName}`.replace(" ", "-")] = savedPart;
}
});
},
},
mounted() {

View file

@ -7,7 +7,7 @@
:answers="styles"
groupName="ChooseVehicleStyle"
textPosition="text-start"
v-model="selectedValueAsArray"
v-model="selectedValue"
isRequired=true
/>
</template>
@ -34,14 +34,13 @@ export default {
questionText(){
return this.getCmsContent(this.cmsWidgetName, 'QuestionText');
},
selectedValueAsArray: {
selectedValue: {
get: function() {
const modelValueAsArray = this.modelValue ? [this.modelValue] : [];
return modelValueAsArray;
},
set: function(newValue) {
const newValueAsScalar = newValue && newValue.length > 0 ? newValue[0] : null;
this.$emit("update:modelValue", newValueAsScalar);
this.$emit("update:modelValue", newValue);
}
}
},

View file

@ -7,7 +7,7 @@
:answers="years"
groupName="ChooseVehicleYear"
textPosition="text-start"
v-model="selectedValueAsArray"
v-model="selectedValue"
isRequired=true
/>
</template>
@ -35,14 +35,13 @@ export default {
questionText(){
return this.getCmsContent(this.cmsWidgetName, 'QuestionText');
},
selectedValueAsArray: {
selectedValue: {
get: function() {
const modelValueAsArray = this.modelValue ? [this.modelValue] : [];
return modelValueAsArray;
},
set: function(newValue) {
const newValueAsScalar = newValue && newValue.length > 0 ? newValue[0] : null;
this.$emit("update:modelValue", newValueAsScalar);
this.$emit("update:modelValue", newValue);
}
}
},

View file

@ -183,15 +183,10 @@ export default {
previouslyEnteredCarId: '',
invalidZip: '',
vinPopulatedOnPageLoad: this.getVinFromStore()?.length > 0,
isInsuranceVerified: false,
};
},
mounted() {
this.attachCustomEvents();
if (this.vinPopulatedOnPageLoad) {
this.setupVinMask();
this.isInsuranceVerified = store.getters.payment.insuranceCoverage.isVerified || getFunnelCookie().HasDelayedClaimRegistration;
}
},
watch: {
vin() {
@ -239,12 +234,20 @@ export default {
return this.getCmsContent("PerfectMatchInsuranceVerifiedAlert", "BodyText").replaceAll("{custom:damage}",
getIsWindshieldOnly())
},
isInsuranceVerified() {
return store.getters.payment.insuranceCoverage.isVerified || getFunnelCookie().HasDelayedClaimRegistration;
},
vinMask() {
if (this.vinPopulatedOnPageLoad) {
const lastSixChars = this.vin.substring(11, this.vin.length);
return `!X!X!X!X!X!X!X!X!X!X!X${lastSixChars}`;
}
else {
return 'XXXXXXXXXXXXXXXXX';
}
},
},
methods: {
setupVinMask() {
const lastSixChars = this.vin.substring(11, this.vin.length);
this.vinMask = `!X!X!X!X!X!X!X!X!X!X!X${lastSixChars}`;
},
arePagePrerequisitesValid() {
return store.getters.vehicle.carId !== null;
},

View file

@ -12,6 +12,7 @@ const fmgPageValues = {
REVEAL: "reveal",
ESTIMATE: "estimate",
ADDRESS_VEHICLES: "address-vehicles",
QUOTE: "quote"
};
export { fmgPageValues };

View file

@ -46,6 +46,7 @@ html {
input[type=radio]+label:before,
input[type=checkbox]+label:before {
border: 1px solid $red;
background-color: initial;
}
input[type=checkbox]:checked + label:before {
border: 1px solid $blue;

View file

@ -223,18 +223,26 @@ export default {
span {
font-size: .875rem;
}
}
}
}
.col {
&:first-of-type {
label {
border-bottom-left-radius: 0.5rem;
border-top-left-radius: 0.5rem;
z-index: 2;
.list-button-horizontal {
label {
border-bottom-left-radius: 0.5rem;
border-top-left-radius: 0.5rem;
z-index: 2;
}
}
}
&:last-of-type {
label {
border-bottom-right-radius: 0.5rem;
border-top-right-radius: 0.5rem;
.list-button-horizontal {
label {
border-bottom-right-radius: 0.5rem;
border-top-right-radius: 0.5rem;
}
}
}
}

View file

@ -205,7 +205,7 @@ describe("list-card.vue", () => {
});
wrapper.vm.handleCheckChange();
// Assert
expect(wrapper.emitted()["isCheckedChanged"][0]).toEqual([{value: "List Card Checkbox", checkValue: [Boolean, String], buttonId: 'list-card-id'}]);
expect(wrapper.emitted()["isCheckedChanged"][0]).toEqual([{value: "List Card Checkbox", checkValue: "List Card Checkbox", buttonId: 'list-card-id'}]);
});
it("Should set checkValue data if selectedButtonIDs has value(s)", async () => {

View file

@ -1,8 +1,9 @@
<template>
<div :class="'col' + colLength">
<div :class="{'h-100': !isWide}">
<div
class="list-card w-100 rounded-3 d-flex align-items-center h-100"
class="list-card w-100 rounded-3 d-flex align-items-center"
:class="[
'h-100',
isWide ? 'horizontal' : '',
(errors.length > 0 || hasError) ? 'has-error' : '',
]"
@ -19,7 +20,8 @@
:value="value"
:aria-required="isRequired"
v-model="checkValue"
@change="handleInputChange()"
:checked="checkValue"
@change="handleInputChange"
/>
<label
tabindex="-1"
@ -27,7 +29,7 @@
:aria-labelledby="buttonID"
class="d-flex w-100 align-items-center px-2 h-100"
:class="getLabelClasses"
@mouseup="triggerButton()"
@mouseup="triggerButton"
>
<img
:id="buttonImageId"
@ -93,7 +95,7 @@ export default {
},
data() {
return {
checkValue: [Boolean, String],
checkValue: null,
}
},
created() {
@ -102,6 +104,14 @@ export default {
? this.selectedValues.includes(this.value)
: this.selectedValues[0];
}
else if (Array.isArray(this.modelValue)) {
this.checkValue = this.isMultiSelect
? this.modelValue.includes(this.value)
: this.modelValue[0];
}
else {
this.checkValue = this.selectedValues == this.value || this.modelValue == this.value;
}
},
unmounted() { // needed to clear this button's selectedValues if it is removed to keep validation in sync
if (this.clearOnUnmount) {
@ -164,6 +174,15 @@ export default {
this.checkValue = newVal.value;
}
},
selectedValues(newVal) {
if (typeof newVal === "string") {
this.handleChange(this.value);
this.checkValue = newVal == this.value;
}
else if (newVal !== undefined) {
this.checkValue = newVal.value;
}
},
},
setup(props) {
const inputType = props.isMultiSelect ? "checkbox" : "radio";

View file

@ -92,7 +92,7 @@ describe("radio.vue", () => {
isRequired: true,
modelValue: ["List Card Checkbox"],
value: "Car-Front",
selectedValues: ["Car-Front"]
selectedValues: "Car-Front"
},
});
// Assert

View file

@ -1,18 +1,19 @@
<template>
<!-- Checkbox groups MUST be wrapped in a <fieldset> and <legend> tag and must contain tabindex -->
<div class="ui-radio form-check" :class="[hasError ? 'has-error' : '']">
<div class="ui-radio form-check" :class="[(errors.length > 0 || hasError) ? 'has-error' : '']">
<input
type="radio"
class="form-check-input"
aria-checked="false"
:name="groupName"
:id="buttonID"
:aria-required="isRequired"
:value="value"
:v-model="checkValue"
@change="handleCheckChange()"
:checked="checkValue"
/>
type="radio"
class="form-check-input"
aria-checked="false"
:name="groupName"
:id="buttonID"
:aria-required="isRequired"
:value="value"
:v-model="checkValue"
@change="handleCheckChange"
:checked="checkValue"
:validationRules="validationRules"
/>
<label class="d-flex align-items-start form-check-label" :for="buttonID">
<p v-if="buttonLabel" class="m-0">{{ buttonLabel }}</p>
<span v-if="screenReaderOnlyText" class="sr-only">{{
@ -37,8 +38,9 @@ export default {
default: "",
},
screenReaderOnlyText: String,
selectedValues: [Array, String],
selectedValues: String,
hasError: Boolean,
validationRules: String
},
data() {
return {
@ -47,16 +49,15 @@ export default {
},
created() {
if (this.selectedValues) {
this.checkValue = this.selectedValues[0] === this.value;
}else{
this.checkValue = this.selectedValues === this.value;
this.handleCheckChange();
} else{
this.checkValue = false;
}
},
methods: {
handleClick(value) {
this.handleChange(value);
},
handleCheckChange() {
this.handleChange(this.value);
const emitEvent = {
checkValue: this.checkValue,
value: this.value.toString(),
@ -88,13 +89,17 @@ export default {
<style lang="scss" scoped>
.form-check {
position: relative;
.form-check-input {
border: 1px solid $gray-500;
border-radius: 100%;
border-radius: 50%;
margin-right: 0.5rem;
&:checked {
background-color: $white;
background-size: 71%;
background-position: center;
border: 1px solid $blue;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 50' xml:space='preserve'%3e%3ccircle cx='25' cy='25' r='25' fill='%231574A1'/%3e%3c/svg%3e");
+ label {
@ -108,7 +113,15 @@ export default {
&:focus {
box-shadow: 0 0 0 2.5px $blue;
}
&, & + label {
margin-top: 0;
position: absolute;
top: 50%;
transform: translateY(-50%);
}
}
&:hover {
.form-check-input {
box-shadow: 0 0 0 4px $blue-300;