Merge pull request #582 from Safelite/feature/CSR-343

Feature/csr 343
This commit is contained in:
katieoh-safelite 2022-06-29 14:36:44 -04:00 committed by GitHub
commit 1405c34ef6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 415 additions and 392 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

@ -36,7 +36,6 @@ export default {
computed: {
questions() {
console.log("answeredQuestions: ", this.answeredQuestions)
const questions = this.questionData.partQuestions.map((q, i) => {
return {
questionText: q.questionText,

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,23 @@ 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);
});
});
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,229 @@
<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() {
return this.PartDataFromApi.partsOrQuestions?.filter(dataForGlassLocationAndName =>
dataForGlassLocationAndName.glassName == this.glassName &&
dataForGlassLocationAndName.glassLocation == this.glassLocation)[0]
.parts?.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

@ -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

@ -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;