Initial commit

This commit is contained in:
brydon1 2023-07-05 13:41:00 -04:00
parent 2815d88858
commit 2d97232d40
6 changed files with 1801 additions and 64 deletions

1036
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -40,6 +40,7 @@
"eslint-plugin-vue": "9.8.0",
"jest": "^27.0.5",
"sass": "^1.32.7",
"sass-loader": "^12.0.0"
"sass-loader": "^12.0.0",
"vitest": "^0.32.4"
}
}

View file

@ -0,0 +1,188 @@
<template>
<div
class="textarea-question"
:class="(errors && errors.length) || hasError ? 'has-error' : ''">
<label
:for="inputId"
class="form-label">
{{ questionText }}
<span
v-if="!isRequired"
class="optional">(Optional)</span>
</label>
<div class="input-wrapper">
<textarea
:id="inputId"
:ref="inputId"
v-model.trim="value"
class="form-control"
:name="inputId"
:rows="[inputRows ?? 3]"
:placeholder="placeholderText"
:disabled="isDisabled"
:required="isRequired"
:maxlength="maxLength"
:validationRules="validationRules"
@paste="trimOnPaste"
@drop="trimOnPaste">
</textarea>
</div>
<p class="character-count margin-top-8">
{{ characterCount }}/{{ maxLength }} characters remaining
</p>
<div
v-show="errorMessage"
class="row my-1 form-test-error">
<span
class="d-inline-flex mt-0"
role="alert">{{ errorMessage }}
</span>
</div>
</div>
</template>
<script>
import { useField } from 'vee-validate';
export default {
name: 'textarea-question',
props: {
placeholderText: {
type: String,
default: ''
},
inputId: {
type: String,
required: true
},
modelValue: String,
isDisabled: Boolean,
isRequired: Boolean,
hasError: Boolean,
validationRules: String,
cmsWidgetName: String,
maxLength: String,
inputRows: Number
},
emits: ['update:modelValue', 'textareaQuestionEvent.inputIdAssigned'],
setup(props) {
const propsClone = { ...props };
const { modelValue } = propsClone;
const initialValue = modelValue ?? '';
const fieldOptions = {
type: 'text',
value: modelValue,
initialValue
};
const { errorMessage,
handleChange,
validate,
errors }
= useField(props.inputId,
props.validationRules,
fieldOptions);
return {
errorMessage,
handleChange,
validate,
errors
};
},
computed: {
characterCount() {
return this?.modelValue?.length ?? 0;
},
questionText() {
return this.getCmsContent(this.cmsWidgetName, 'QuestionText');
},
value: {
get() {
return this.modelValue;
},
set(newValue) {
this.$emit('update:modelValue', newValue);
}
}
},
watch: {
async value(newValue) {
const result = await this.validate(newValue, this.validationRules);
if (result.valid) {
this.handleChange(newValue);
}
}
},
mounted() {
// TODO do we need to do anything else for this event?
this.$emit('textareaQuestionEvent.inputIdAssigned', this.inputId);
},
methods: {
trimOnPaste(evt) {
evt.stopPropagation();
evt.preventDefault();
const data = evt.type === 'paste' ? (evt.clipboardData || window.clipboardData) : evt.dataTransfer;
const value = data.getData('Text')?.trim();
this.$emit('update:modelValue', value);
}
}
};
</script>
<style lang="scss">
.textarea-question {
label {
color: $black;
font-weight: 500;
}
.form-test-error span{
color: $red;
font-size: 0.875rem;
font-weight: 500;
}
.form-control {
max-height: 5rem;
border: 1px solid $gray-500;
border-radius: 0.5rem;
padding: 12px 16px;
&::placeholder {
color: $gray-500;
}
&:focus {
box-shadow: 0 0 0 2.5px $blue;
}
&:disabled,
&.disabled {
background-color: $gray-100;
&:hover {
box-shadow: 0 0 0 4px transparent;
border: 1px solid $gray-500;
}
}
&:hover {
border: 1px solid $gray-500;
box-shadow: 0 0 0 4px $blue-300;
}
}
}
.optional {
color: $gray-550;
}
.character-count {
color: $gray-600;
font-size: 12px;
margin-bottom: 0px;
}
.margin-top-8 {
margin-top: 8px;
}
</style>

View file

@ -0,0 +1,395 @@
// Components
import contactDetails from '@/layouts/contact-details/contact-details.vue';
// TODO finish this file
// Supporting Files
import { shallowMount } from '@vue/test-utils';
import { getMountOptions } from '@/helpers/unit-test-helper';
import { useMainStore } from '@/store';
import vehicleQuestionsMixin from '@/mixins/vehicle-questions-mixin';
import { nextTick } from 'vue';
import { vi } from 'vitest';
import baseMixin from '../../mixins/base-mixin';
// Mock our module for promises.
vi.mock('@/helpers/layout-helper.js', () =>
({
settleAllPromises: vi.fn()
}));
// Mock fetchCmsContentForPage
vi.mock('@/helpers/cms-content-helper', () =>
({
fetchCmsContentForPage: vi.fn()
}));
const baseStoreGettersPageData = () =>
({
partsOrQuestions: [
{
parts: [
{
childPartQuestions: []
}
],
contactDetails: [
{
questionSequence: 1,
questionText:
'Is your vehicle equipped with the optional Lane-Keeping System which tugs on the steering wheel and/or beeps to alert you if you drift too close to the edge of the lane?',
answers: [
{
answerResult1: 'DYNAMIC',
answerResult2: '1',
answerText: 'Yes',
nextQuestionSequence: null,
answerResult: 'DYNAMIC'
},
{
answerResult1: 'Unknown',
answerResult2: '0',
answerText: 'No',
nextQuestionSequence: null,
answerResult: 'Unknown'
}
]
}
],
partQuestions: [],
questions: [],
glassLocation: 'Windshield',
glassName: 'Single',
answerKey: 'Windshield-Single',
answerData: null
}
]
});
const baseStoreGettersDamage = () =>
({
partsQuestionAnswers: [
{
glassLocation: 'Windshield',
glassName: 'Single',
result: 'FW04848',
answeredQuestions: [
{
questionText:
'Is your vehicle equipped with the Panoramic Sunroof which can be identified by having a glass panel over the rear seats?',
selectedAnswer: '1|nextQuestion|3|Yes',
selectedAnswerText: 'Yes',
questionNum: 1
},
{
questionText:
'Is your vehicle equipped with a heated windshield that melts snow and ice from underneath the windshield wiper blades?',
selectedAnswer: '2|nextQuestion|3|Yes',
selectedAnswerText: 'Yes',
questionNum: 2
}
]
}
]
});
useMainStore().pageData = baseStoreGettersPageData;
useMainStore().damage = baseStoreGettersDamage;
describe('contactDetails.vue', () => {
describe('method arePagePrerequisitesValid...', () => {
test('Should return true for valid page requisites if pageData exists', () => {
// Arrange
const { wrapper } = setupMocks({});
// Act
const result = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(result).toBe(true);
wrapper.unmount();
});
test('Should return false for valid page requisites if partsOrQuestions in pageData is missing', () => {
// Arrange
const { wrapper } = setupMocks({});
useMainStore().pageData = vi.fn(() =>
undefined);
// Act
const result = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(result).toBeFalsy();
wrapper.unmount();
});
test('Should be at least one item in partsOrQuestions', () => {
// Arrange
useMainStore().pageData = vi.fn(() =>
({
partsOrQuestions: []
}));
useMainStore().damage = baseStoreGettersDamage;
const { wrapper } = setupMocks({});
// Act
const result = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(result).toBeFalsy();
wrapper.unmount();
});
});
describe('watch on selectedAnswers should be set up...', () => {
test('Should trigger handleAnswerUpdates if watched data changes', async () => {
// Arrange
useMainStore().pageData = baseStoreGettersPageData;
useMainStore().damage = baseStoreGettersDamage;
const { wrapper } = setupMocks({});
const spy = vi.spyOn(wrapper.vm, 'handleAnswerUpdates');
// Act
wrapper.setData({
selectedAnswers: {
'Windshield-Single': {
answerResult: 'FW04848',
answeredQuestions: [
{
questionText:
'Is your vehicle equipped with the Panoramic Sunroof which can be identified by having a glass panel over the rear seats?',
selectedAnswer: '1|nextQuestion|3|Yes',
selectedAnswerText: 'Yes',
questionNum: 1
},
{
questionText:
'Is your vehicle equipped with a heated windshield that melts snow and ice from underneath the windshield wiper blades?',
selectedAnswer: '2|nextQuestion|3|Yes',
selectedAnswerText: 'Yes',
questionNum: 2
}
],
index: 0
}
}
});
await nextTick();
// Assert
expect(spy).toHaveBeenCalled();
wrapper.unmount();
});
});
describe('forwardButtonAction', () => {
test('Should clear out answerData', () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.questionsData = [
{
glassLocation: 'Windshield',
glassName: 'Single',
answerData: {
answerResult: 'FW04848',
answeredQuestions: []
},
questions: [],
parts: [
{
childPartQuestions: [{}]
}
]
}
];
wrapper.vm.dispatchStoreAction = vi.fn(() =>
({
data: []
}));
// Act
wrapper.vm.forwardButtonAction();
// Assert
expect(wrapper.vm.questionsData[0].answerData).toEqual({});
wrapper.unmount();
});
test('Should save to pinia store', async () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.questionsData = [
{
glassLocation: 'Windshield',
glassName: 'Single',
answerData: {
answerResult: 'FW04848',
answeredQuestions: []
},
questions: [],
parts: [
{
childPartQuestions: []
}
]
}
];
useMainStore().getPartsOrQuestions = vi.fn(() =>
({
data: {
partsOrQuestions: []
}
}));
// Act
wrapper.vm.forwardButtonAction();
await nextTick();
// Assert
expect(wrapper.vm.saveCapabilityQuestionAnswers).toHaveBeenCalled;
wrapper.unmount();
});
test('Should call GET_PART_FROM_CAPABILITY_QUESTION_ANSWER API', async () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.questionsData = [
{
glassLocation: 'Windshield',
glassName: 'Single',
answerData: {
answerResult: 'FW04848',
answeredQuestions: []
},
questions: [],
parts: [
{
childPartQuestions: []
}
]
}
];
wrapper.vm.dispatchStoreAction = vi.fn(() =>
({
data: []
}));
// Act
wrapper.vm.forwardButtonAction();
await nextTick();
// Assert
expect(wrapper.vm.getPartFromCapabilityQuestionAnswer).toHaveBeenCalled;
wrapper.unmount();
});
test('Should trigger navigateForward', async () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.questionsData = [
{
glassLocation: 'Windshield',
glassName: 'Single',
answerData: {
answerResult: 'FW04848',
answeredQuestions: []
},
questions: [],
parts: [
{
childPartQuestions: []
}
]
}
];
useMainStore().getPartsOrQuestions = vi.fn(() =>
({
data: {
partsOrQuestions: []
}
}));
wrapper.vm.navigateForward = vi.fn();
// Act
await wrapper.vm.forwardButtonAction();
// Assert
expect(wrapper.vm.navigateForward).toHaveBeenCalled();
wrapper.unmount();
});
});
});
function setupMocks({
mountOptionsMockData = {
router: {
navigate: vi.fn()
},
actionList: [
{
actionName: 'saveCapabilityQuestionAnswers',
data: {}
},
{
actionName: 'getPartsOrQuestions',
data: {}
}
],
route: {
query: {
issPage: 'capability-questions'
}
},
data() {
return {
computedSwitcher: [
{
glassLocation: 'Windshield',
glassName: 'Single',
answerData: {
answerResult: 'FW04848',
answeredQuestions: []
}
}
]
};
},
questionsData: {
get() {
return this.computedSwitcher;
},
set(val) {
this.computedSwitcher = val;
}
}
}
}) {
useMainStore().getPartsOrQuestions = vi.fn(() =>
({
data: {
partsOrQuestions: []
}
}));
const mountOptions = getMountOptions({
...mountOptionsMockData,
mixins: [baseMixin, vehicleQuestionsMixin]
});
mountOptions.attachTo = document.body;
const wrapper = shallowMount(contactDetails, mountOptions);
return { wrapper };
}

View file

@ -1,17 +1,91 @@
<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm" v-slot="{ meta }" >
<Form
ref="contact-details-form"
v-slot="{ meta }"
@submit="onSubmit"
@invalidSubmit="onInvalidSubmit">
<div class="page-container-grouped-styles">
<div class="fade-on-route-transition position-relative">
<siteHeader cmsWidgetName="SiteHeaderWidget"/>
<siteHeader cmsWidgetName="SiteHeaderWidget" />
<div class="container-fluid pb-2">
<p>Placeholder for contact details page</p>
<siteSubHeader
id="sub-header"
class="margin-top-16"
cmsWidgetName="SiteSubHeaderWidget" />
<textboxQuestion
ref="firstName"
v-model="firstName"
class="margin-top-24"
inputId="firstName"
cmsWidgetName="FirstNameQuestionWidget"
isRequired
validationRules="first-name-required" />
<textboxQuestion
ref="lastName"
v-model="lastName"
class="margin-top-16"
inputId="lastName"
cmsWidgetName="LastNameQuestionWidget"
isRequired
validationRules="last-name-required" />
<textboxQuestion
ref="email"
v-model="email"
class="margin-top-16"
inputId="email"
cmsWidgetName="EmailQuestionWidget"
isRequired
validationRules="email-address-required|email-address-format" />
<textboxQuestion
ref="firstName"
v-model="phoneNumber"
class="margin-top-16"
inputId="phoneNumber"
cmsWidgetName="PhoneNumberQuestionWidget"
isRequired
validationRules="phone-number-required|phone-number-format" />
<checkbox
ref="requestTextUpdates"
v-model="requestTextUpdates"
class="margin-top-8"
checkboxName="requestTextUpdates"
buttonID="requestTextUpdates"
:checkboxLabel="textContentText" />
<textareaQuestion
v-model="notesForTechnician"
class="margin-top-16"
inputId="technicianNotes"
:isDisabled="false"
:isRequired="false"
hasError="hasError"
cmsWidgetName="NotesQuestionWidget"
maxLength="500"
inputRows="30" />
<p class="disclaimer margin-top-24">
{{ textUpdateDisclaimerText }} I also agree to Safelite's
<textLink
class="disclaimer-link"
linkType="text"
text="Privacy Policy"
href="//www.safelite.com/privacy-center"
target="_blank" />
and
<textLink
class="disclaimer-link"
linkType="text"
text="Terms of Use"
href="//www.safelite.com/terms-of-use"
target="_blank" />.
</p>
<siteFooter
cmsWidgetName="SiteFooterWidget"
ref="siteFooter"
:isForwardActionDisabled="!meta.valid"
@ForwardClicked="navigateForward"
@back-clicked="backButtonAction"
/>
ref="siteFooter"
cmsWidgetName="SiteFooterWidget"
:isForwardActionDisabled="!meta.valid"
@forwardClicked="navigateForward"
@backClicked="backButtonAction" />
</div>
</div>
</div>
@ -19,67 +93,132 @@
</template>
<script>
// Components
import siteHeader from '@/iss-components/site-header/site-header';
import siteFooter from "@/iss-components/site-footer/site-footer";
import siteHeader from '@/iss-components/site-header/site-header.vue';
import siteFooter from '@/iss-components/site-footer/site-footer.vue';
import siteSubHeader from '@/iss-components/site-sub-header/site-sub-header.vue';
import textboxQuestion from '@/digital-components/textbox-question/textbox-question.vue';
import checkbox from '@/ux-components/checkbox/checkbox.vue';
import textareaQuestion from '@/digital-components/textarea-question/textarea-question.vue';
import textLink from '@/ux-components/text-link/text-link.vue';
// Supporting files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
import { Form } from "vee-validate";
import BaseFormMixin from '@/mixins/base-form-mixin.js';
import { fetchCmsContentForPage } from '@/helpers/cms-content-helper';
import { settleAllPromises } from '@/helpers/layout-helper';
import { Form, defineRule } from 'vee-validate';
import { errorMessages } from '@/constants/error-messages';
import BaseFormMixin from '@/mixins/base-form-mixin';
import { useMainStore } from '@/store';
import { required, regex } from '@/helpers/validation-rules';
// TODO define these globally and reuse
defineRule('first-name-required', required(errorMessages.FIRST_NAME_REQUIRED));
defineRule('last-name-required', required(errorMessages.LAST_NAME_REQUIRED));
defineRule('phone-number-required', required(errorMessages.PHONE_NUMBER_REQUIRED));
defineRule('phone-number-format',
regex(/^(\([0-9]{3}\)|[0-9]{3}) *[-.]? *[0-9]{3} *[-.]? *[0-9]{4}$/,
errorMessages.PHONE_NUMBER_FORMAT));
defineRule('email-address-required', required(errorMessages.EMAIL_ADDRESS_REQUIRED));
defineRule('email-address-format',
regex(/^([a-zA-Z0-9_\-.+]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,})$/,
errorMessages.EMAIL_ADDRESS_FORMAT));
export default {
name: "contact-details",
name: 'contact-details',
components: {
siteHeader,
siteSubHeader,
textboxQuestion,
checkbox,
textareaQuestion,
siteFooter,
// eslint-disable-next-line vue/no-reserved-component-names
Form,
textLink
},
mixins: [BaseFormMixin],
data() {
},
setup() {
const mainStore = useMainStore();
return { mainStore };
},
async beforeRouteEnter(to, from, next)
{
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.issPage);
// Settle promises and get results
const promiseResultMap = [
{
resultKey: "cmsContent",
promise: cmsContentPromise,
},];
resultKey: 'cmsContent',
promise: cmsContentPromise
}
];
//use resultMap to populate layout content.
let resultMap = await settleAllPromises(promiseResultMap);
// use resultMap to populate layout content.
const resultMap = await settleAllPromises(promiseResultMap);
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
});
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
});
},
data() {
return {
firstName: useMainStore().order.customer.firstName,
lastName: useMainStore().order.customer.lastName,
email: useMainStore().order.customer.emailAddress,
phoneNumber: useMainStore().order.customer.phoneNumber,
requestTextUpdates: false,
notesForTechnician: ''
};
},
computed: {
textContentText() {
return `${this.getCmsContent('TextContentWidget', 'Text')}*`;
},
methods:
{
backButtonAction() {
this.$router.navigate(this.navigationScenarios.CLICKED_BACK, this.$route);
},
forwardButtonAction() {
},
navigateForward() {
this.$router.navigate(
this.navigationScenarios.CLICKED_FORWARD,
this.$route
);
},
textUpdateDisclaimerText() {
return `*${this.getCmsContent('TextUpdateDisclaimerWidget', 'Text')}`;
}
},
methods:
{
backButtonAction() {
this.$router.navigate(this.navigationScenarios.CLICKED_BACK, this.$route);
},
components: {
siteHeader,
siteFooter,
Form,
forwardButtonAction() {
this.navigateForward();
},
navigateForward() {
this.$router.navigate(this.navigationScenarios.CLICKED_FORWARD,
this.$route);
}
}
};
</script>
<style lang="scss">
#sub-header {
span {
color: $black;
}
}
.margin-top-24 {
margin-top: 24px;
}
.margin-top-16 {
margin-top: 16px;
}
.margin-top-8 {
margin-top: 8px;
}
.disclaimer {
font-size: 12px;
color: $darker-gray;
font-weight: 400;
}
.disclaimer-link {
text-decoration: none;
line-height: normal;
}
</style>