Merge remote-tracking branch 'origin/develop' into feature/skiener/INSR-2124

This commit is contained in:
scottkiener-at-safelite 2026-04-06 09:58:37 -04:00
commit 6e2e7d8ee3
13 changed files with 676 additions and 148 deletions

View file

@ -342,6 +342,7 @@ stages:
- template: templates/digital/auto-tag.yml@AzureDevOps
parameters:
dependsOn: prodBuildDeployment
environment: digitalCloud-prod
userName: SafeliteAzureDevops
userEmail: githubazuredevops@safelite.com

View file

@ -73,3 +73,22 @@ export function containsRecalParts(lineItems) {
}
}
export function anyPartWithRequiresRecalFlag(lineItems) {
if (!lineItems) {
return false;
}
if (Array.isArray(lineItems)) {
return lineItems.some((li) => li.requiresRecalibration === true);
}
const flattened = [
...(lineItems.glassParts ?? []),
...(lineItems.supportingItems ?? []),
...(lineItems.otherParts ?? []),
...(lineItems.feeItems ?? []),
...(lineItems.vaps ?? []),
];
return flattened.some((li) => li.requiresRecalibration === true);
}

View file

@ -30,6 +30,7 @@ export default {
default: 'RecalModal'
}
},
emits: ['recal-modal-opened'],
computed: {
modalBodyText() {
return this.getCmsContent(this.cmsWidgetName, 'BodyText').replaceAll('{custom:vehicleMake}', this.vehicleMake);
@ -50,6 +51,7 @@ export default {
methods: {
openModal() {
this.$refs.recalModal.openModal();
this.$emit('recal-modal-opened');
},
footerButtonClick() {
this.$refs.recalModal.closeModal();

View file

@ -14,6 +14,7 @@ import { useMainStore } from '@/store';
import { getPriceOfLineItems } from '@/helpers/price-calculator.js';
import coverageStatuses from '@/constants/coverage-statuses';
import coverageType from '@/constants/coverage-type';
import { containsRecalParts } from '@/helpers/recal-helper';
jest.mock('@/helpers/layout-helper.js', () => jest.fn());
jest.mock('@/helpers/price-calculator.js', () => ({
@ -36,8 +37,6 @@ jest.mock('@/helpers/recal-helper.js', () => ({
containsRecalParts: jest.fn()
}));
import { containsRecalParts } from '@/helpers/recal-helper.js';
const SAFELITE_PROVIDER = 'Safelite';
const CANCEL_CLAIM_REF_NAME = 'CancelClaimModal';
@ -115,7 +114,8 @@ describe('coverageStatement.vue', () => {
const mainInitialState = {
order: {
damage: {
isRepair: true
isRepair: true,
numberOfChips: 2
},
insuranceCoverage: {
coverageStatus: coverageStatuses.PENDING,
@ -343,7 +343,7 @@ describe('coverageStatement.vue', () => {
// Assert
expect(result).toBeFalsy();
});
test('returns true when a part in glassParts require recalibration and recalibration has been added to order', () => {
test('returns true when a part in glassParts requires recalibration and recalibration has been added to order', () => {
// Arrange
containsRecalParts.mockReturnValue(true);
const mainInitialState = {
@ -614,7 +614,8 @@ describe('coverageStatement.vue', () => {
const mainInitialState = {
order: {
damage: {
isRepair: true
isRepair: true,
numberOfChips: 2
},
insuranceCoverage: {
coverageStatus: coverageStatuses.PENDING,
@ -644,7 +645,8 @@ describe('coverageStatement.vue', () => {
const mainInitialState = {
order: {
damage: {
isRepair: true
isRepair: true,
numberOfChips: 2
},
insuranceCoverage: {
coverageStatus: coverageStatuses.VERIFIED,
@ -673,7 +675,8 @@ describe('coverageStatement.vue', () => {
const mainInitialState = {
order: {
damage: {
isRepair: true
isRepair: true,
numberOfChips: 2
},
insuranceCoverage: {
coverageStatus: coverageStatuses.VERIFIED,
@ -705,7 +708,8 @@ describe('coverageStatement.vue', () => {
const mainInitialState = {
order: {
damage: {
isRepair: true
isRepair: true,
numberOfChips: 2
},
insuranceCoverage: {
coverageStatus: coverageStatuses.VERIFIED,
@ -732,7 +736,8 @@ describe('coverageStatement.vue', () => {
const mainInitialState = {
order: {
damage: {
isRepair: true
isRepair: true,
numberOfChips: 2
},
insuranceCoverage: {
coverageStatus: coverageStatuses.VERIFIED,
@ -763,7 +768,8 @@ describe('coverageStatement.vue', () => {
const mainInitialState = {
order: {
damage: {
isRepair: true
isRepair: true,
numberOfChips: 2
},
insuranceCoverage: {
coverageStatus: coverageStatuses.VERIFIED,

View file

@ -103,14 +103,16 @@
</div>
<recalModal
:ref="RECAL_MODAL_REF_NAME"
cmsWidgetName="RecalModal" />
cmsWidgetName="RecalModal"
@recalModalOpened="handleRecalModalOpened" />
<cancelClaimModal
:ref="CANCEL_CLAIM_REF_NAME"
@cancelClaimConfirmation="cancelClaim"
@returnToClaim="navigateForward" />
<oemEndorsementModal
:ref="OEM_ENDORSEMENT_REF_NAME"
cmsWidgetName="OEMEndorsementModal" />
cmsWidgetName="OEMEndorsementModal"
@oemEndorsementModalOpened="handleOemEndorsementModalOpened" />
</Form>
</template>
@ -377,6 +379,71 @@ export default {
setupModalLinks(this);
},
methods: {
logEventsToGA() {
const clientName = this.mainStore.accountNameForEvents;
if (this.isDeductibleVisible || this.isITACQuoteVisible) {
this.pushEventToGA('coverage', 'Yes', clientName, true, null, '1');
if (this.isITACQuoteVisible) {
this.pushEventToGA('Coverage', 'ITAC', `${this.deductibleValue}_${this.totalServicePrice}`, true);
}
else {
this.pushEventToGA('coverage', 'full_comprehensive', clientName, true);
this.pushEventToGA('coverage', 'deductible', this.deductibleValue.toString(), true);
}
}
else {
this.pushEventToGA('coverage', 'No', clientName, true, null, '0');
if (this.isNoCompQuoteVisible) {
this.pushEventToGA('coverage', 'no_comprehensive', `${this.totalServicePrice}`, true);
this.pushEventToGA('coverage', 'deductible', '9999', true);
}
else {
this.pushEventToGA('coverage', 'deductible', '7777', true);
}
}
if (this.mainStore.hasOemEndorsement) {
this.pushEventToGA('oem_endorsement', 'displayed', 'Yes', true, null, '1');
}
else {
this.pushEventToGA('oem_endorsement', 'displayed', 'No', true, null, '0');
}
if (this.isADAS) {
if (this.mainStore.vehicle.canSafeliteService) {
this.pushEventToGA('ADAS_self_service', 'ADAS_servicable_status', 'yes', true);
if (this.mainStore.hasRecalibrationPart) {
if (this.isDeductibleVisible) {
const recalibrationType = this.mainStore.lineItems.glassParts.find((part) => part.requiresRecalibration).recalibrationType;
if (this.deductibleValue <= 0) {
this.pushEventToGA('recalibration_opportunity_verified_0_deductible', this.mainStore.vehicle.carId, `recal_type_${recalibrationType}`, true);
}
else {
this.pushEventToGA('recalibration_opportunity_verified_over_0_deductible', this.mainStore.vehicle.carId, `recal_type_${recalibrationType}`, true);
}
}
else {
let appointmentType = '';
if (this.isITACQuoteVisible) {
appointmentType = 'ITAC';
}
else if (this.isNoCompQuoteVisible) {
appointmentType = 'no_comp';
}
else {
appointmentType = 'unverified';
}
this.pushEventToGA(`recalibration_opportunity_${appointmentType}`, this.mainStore.vehicle.carId, `recal_type_${this.mainStore.lineItems.glassParts.find((part) => part.requiresRecalibration).recalibrationType}`, true);
}
}
}
else {
this.pushEventToGA('ADAS_self_service', 'ADAS_servicable_status', 'no', true);
}
}
},
arePagePrerequisitesValid() {
return !!useMainStore().vehicle.carId;
},
@ -396,6 +463,8 @@ export default {
}
}
this.logEventsToGA();
showIssLoadingModal(false);
this.isPageLoading = false;
},
@ -414,7 +483,13 @@ export default {
},
async navigateForward() {
showIssLoadingModal(true);
if (this.isUnverifiedVisible || this.isDeductibleVisible) {
if (this.isUnverifiedVisible || this.isDeductibleVisible) {
if (this.isRepair) {
this.pushEventToGA('RepairDamage', 'Repair', this.mainStore.damage.numberOfChips.toString(), true, null, '1');
}
else {
this.pushEventToGA('RepairDamage', 'Replacement', 'Replacement', true, null, '1');
}
this.navigateWithScenario(navigationScenarios.CLICKED_FORWARD);
} else if (this.isITACQuoteVisible || this.isNoCompQuoteVisible) {
this.mainStore.updateIsSafeliteProvider(true);
@ -478,7 +553,13 @@ export default {
cancelClaim() {
this.mainStore.updateIsSafeliteProvider(false);
this.$router.navigateBailout(bailoutMessage.CoverageCancelledByUser());
}
},
handleRecalModalOpened() {
this.pushEventToGA('recalibration_modal', 'learn_more_clicked', this.mainStore.vehicle.carId, true);
},
handleOemEndorsementModalOpened() {
this.pushEventToGA('oem_endorsement_modal', 'clicked', 'Yes', true, null, '1');
},
}
};
</script>

View file

@ -26,6 +26,7 @@ export default {
default: 'OEMEndorsementModal'
}
},
emits: ['oem-endorsement-modal-opened'],
computed: {
modalBodyText() {
return this.getCmsContent(this.cmsWidgetName, 'BodyText');
@ -40,6 +41,7 @@ export default {
methods: {
openModal() {
this.$refs.oemEndorsementModal.openModal();
this.$emit('oem-endorsement-modal-opened');
},
footerButtonClick() {
this.$refs.oemEndorsementModal.closeModal();

View file

@ -193,7 +193,7 @@ export default {
appointmentType: serviceLocation.appointmentType,
providerAddress: serviceLocation?.provider?.address,
customerPortalLoginToken,
carrierName: issConfig.clientName,
carrierName: issConfig.clientDisplayName,
carrierUrl: issConfig.successReturnURL,
isRepair: damage.isRepair,
hasRecalibrationPart,

View file

@ -6,11 +6,12 @@
linkType="text"
href="#!"
:text="textForToggle"
@click="handleClickToggle" />
<div
@click-event="handleClickToggle" />
<button
type="button"
class="tpa-toggle d-inline-block"
:class="[isDetailVisible ? 'active' : '']"
@click="handleClickToggle"></div>
@click="handleClickToggle"></button>
<Transition>
<div
v-show="isDetailVisible"
@ -60,6 +61,8 @@ export default {
.tpa-recal-toggle {
.tpa-toggle {
cursor: pointer;
border: none;
background: none;
&::after {
content: "";

View file

@ -0,0 +1,151 @@
<template>
<div id="recal-ack-modal-container">
<modal
:ref="modalName"
:modalId="modalName"
:footerButtonText="modalCloseButtonText"
@footerButtonEvent="footerButtonClick"
:displayCloseButton="false"
:displayCloseLink="true">
<div class="form-test-invalid">
<h5 class="modal-headline">{{ modalHeadline }}</h5>
<div>
<div
class="mb-4 modal-body-text"
v-html="modalBodyText"></div>
<recalAckToggle
class="mb-3"
cmsWidgetName="RecalAckModalToggle" />
<checkBox
ref="recalAcknowledgement"
v-model="isAcknowledged"
class="mb-4 mt-4 acknowledgement-checkbox"
:class="showAcknowledgementError && ' has-error'"
:validationRules="rules.optionRequired"
checkboxName="recalAcknowledgement"
buttonID="recalAcknowledgement"
:tabIndex="0"
:checkboxLabel="modalSubBodyText"
:screenReaderOnlyText="modalSubBodyText"
isRequired />
<div
v-if="showAcknowledgementError"
class="row form-test-error mt-1">
<p>{{ errorMessage }}</p>
</div>
</div>
</div>
</modal>
</div>
</template>
<script>
import modal from "@/digital-components/modal/modal";
import recalAckToggle from '@/layouts/schedule-page/recal-ack-modal/recal-ack-toggle/recal-ack-toggle.vue';
import checkBox from '@/ux-components/checkbox/checkbox.vue';
import globalRules from '@/constants/global-rules';
export default {
name: "recal-ack-modal",
components: {
modal,
checkBox,
recalAckToggle
},
props: {
cmsWidgetName: String,
ackError: String
},
data() {
return {
isAcknowledged: false,
showAcknowledgementError: false,
rules: {
optionRequired: globalRules.OPTION_REQUIRED
}
};
},
computed: {
modalName() {
return this.cmsWidgetName;
},
modalHeadline() {
return this.getCmsContent(this.cmsWidgetName, 'HeaderText');
},
modalBodyText() {
return this.getCmsContent(this.cmsWidgetName, 'BodyText');
},
modalSubBodyText() {
return this.getCmsContent(this.cmsWidgetName, 'BodyText2');
},
modalCloseButtonText() {
return this.getCmsContent(this.cmsWidgetName, 'FooterText');
},
errorMessage() {
if (this.showAcknowledgementError) {
return this.ackError;
}
return '';
},
},
watch: {
isAcknowledged() {
this.showAcknowledgementError = false;
}
},
methods: {
openModal() {
this.$refs[this.modalName].openModal();
},
footerButtonClick() {
if (this.isAcknowledged) {
this.$emit('recalAcknowledged', this.isAcknowledged);
this.$refs[this.modalName]?.closeModal();
} else {
this.showAcknowledgementError = true;
}
}
},
};
</script>
<style lang="scss" scoped>
.modal-headline {
font-size: 1.625rem;
font-weight: 300;
}
.modal-body-text {
color: #4d4e53;
:deep(strong) {
font-weight: 600;
}
:deep(p:first-of-type) {
margin-bottom: 0.5rem;
}
}
.acknowledgement-checkbox {
:deep(p) {
font-size: 1rem;
color: #4d4e53;
}
:deep(.form-check-input) {
&:checked + label p {
font-size: 1rem;
color: #4d4e53;
font-weight: 500;
}
}
}
:deep(.modal) {
.modal-footer {
button {
background-color: $green;
color: $white;
font-weight: $font-weight-bold;
&:hover, &:focus {
background-color: $green;
box-shadow: 0 0 0 3px $white, 0 0 0 5.5px $green;
}
}
}
}
</style>

View file

@ -0,0 +1,115 @@
<template>
<div class="recal-ack-toggle">
<textLink
id="moreDetails"
class="d-inline-block"
linkType="text"
href="#!"
:text="textForToggle"
:preventDefaultOnClick="true"
@click-event="handleClickToggle" />
<button
type="button"
class="recal-ack-toggle d-inline-block"
:class="[isDetailVisible ? 'active' : '']"
@click="handleClickToggle"></button>
<Transition>
<div
v-show="isDetailVisible"
id="recal-ack-detail-wrapper"
class="mt-2">
<div
id="recal-ack-detail-content"
class="mb-2"
v-html="detailContent" />
</div>
</Transition>
</div>
</template>
<script>
import textLink from '@/ux-components/text-link/text-link.vue';
export default {
name: 'recal-ack-toggle',
components: {
textLink
},
props: {
cmsWidgetName: String
},
data() {
return {
isDetailVisible: false
};
},
computed: {
textForToggle() {
return this.getCmsContent(this.cmsWidgetName, 'HeaderText');
},
detailContent() {
return this.getCmsContent(this.cmsWidgetName, 'BodyText');
}
},
methods: {
handleClickToggle() {
this.isDetailVisible = !this.isDetailVisible;
}
}
};
</script>
<style lang="scss" scoped>
@import "@/styles/ux-variables-svg-strings.scss";
.recal-ack-toggle {
.recal-ack-toggle {
cursor: pointer;
border: none;
background: none;
&::after {
content: "";
transition: all 0.5s ease;
background-image: url($svg-tpa-recal-modal-toggle);
background-repeat: no-repeat;
margin-left: 0.5rem;
width: 16px;
height: 9px;
display: inline-block;
}
&.active::after {
transform: rotate(180deg);
}
a {
font-size: 0.875rem;
}
}
}
#recal-ack-detail-wrapper {
// START - Vue Transition
&.v-enter-active,
&.v-leave-active {
transition: opacity 250ms ease-in;
}
&.v-enter-from,
&.v-leave-to {
opacity: 0;
}
// END - Vue Transition
img {
width: 100%;
}
}
#recal-ack-detail-content {
font-size: 1rem;
p {
font-size: 1rem;
margin-bottom: 0;
color: #4d4e53;
}
ol {
margin-top: 0.5rem;
margin-bottom: 0;
}
}
</style>

View file

@ -12,6 +12,8 @@ jest.mock('@/helpers/cms-content-helper', () => ({
fetchCmsContentForPage: jest.fn()
}));
const RECAL_ACK_MODAL_REF_NAME = 'recalAckModal';
const mockMixin = {
methods: {
getCmsContent: jest.fn().mockImplementation(() => ''),
@ -55,6 +57,22 @@ const loadingModalStub = {
}
};
const serviceLocationStub = {
render: () => {},
methods: {
forwardButtonAction: jest.fn(),
initializeComponent: jest.fn()
}
};
const recalAckModalStub = {
template: '<div></div>',
methods: {
openModal: jest.fn(),
footerButtonClick: jest.fn()
}
};
function getShallowMountedComponent(initialData = {}, methodToRun = () => {}) {
const mountOptions = getMountOptions({
router: {
@ -64,7 +82,9 @@ function getShallowMountedComponent(initialData = {}, methodToRun = () => {}) {
mountOptions.global.stubs = {
siteFooter: footerStub,
loadingModal: loadingModalStub
loadingModal: loadingModalStub,
serviceLocation: serviceLocationStub,
recalAckModal: recalAckModalStub
};
methodToRun();
@ -75,6 +95,27 @@ function getShallowMountedComponent(initialData = {}, methodToRun = () => {}) {
);
const wrapper = shallowMount(schedule, mountOptions);
// Manually create the ref for recalAckModal since shallowMount doesn't populate it
Object.defineProperty(wrapper.vm.$refs, RECAL_ACK_MODAL_REF_NAME, {
value: {
openModal: jest.fn(),
footerButtonClick: jest.fn()
},
writable: false,
configurable: true
});
// Manually create the ref for serviceLocation since shallowMount doesn't populate it
Object.defineProperty(wrapper.vm.$refs, 'serviceLocation', {
value: {
forwardButtonAction: jest.fn(),
initializeComponent: jest.fn()
},
writable: false,
configurable: true
});
return { wrapper };
}
@ -126,152 +167,163 @@ afterEach(() => {
jest.clearAllMocks();
});
describe('schedule-page.vue', () => {
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', { value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn()
})),
writable: true });
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', { value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn()
})),
writable: true });
});
describe('Initial Load', () => {
test('Should pass arePagePrerequisitesValid with a serviceLocation zipcode [in beforeEach]', () => {
// Arrange
const { wrapper } = getShallowMountedComponent();
// Act
const arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(arePagePrerequisitesValid).toBe(true);
});
describe('Initial Load', () => {
test('Should pass arePagePrerequisitesValid with a serviceLocation zipcode [in beforeEach]', () => {
// Arrange
const { wrapper } = getShallowMountedComponent();
test('Should fail arePagePrerequisitesValid with no serviceLocation zipcode', () => {
// Arrange
const { wrapper } = getShallowMountedComponent();
wrapper.vm.mainStore.order.serviceLocation.zipCode = null;
// Act
const arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
// Act
const arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(arePagePrerequisitesValid).toBe(true);
});
test('Should fail arePagePrerequisitesValid with no serviceLocation zipcode', () => {
// Arrange
const { wrapper } = getShallowMountedComponent();
wrapper.vm.mainStore.order.serviceLocation.zipCode = null;
// Act
const arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
// Assert
expect(arePagePrerequisitesValid).toBeFalsy();
});
test('should return newShopTimeSlots when getAvailableDatesMethod is called', async () => {
// Arrange
const { wrapper } = getShallowMountedComponent();
wrapper.vm.selectableDatesData = {
days: []
};
const store = useMainStore();
store.getShopTimeSlots.mockImplementation(() => Promise.resolve({
data: {
estimatedServiceMinutesMinimum: 90,
estimatedServiceMinutesMaximum: 120,
days: [
{
date: '2023-12-01',
timeSlots: [
{
id: '06747-01820-S-B*20424*7 AM',
startTime: '07:00',
endTime: '08:00',
offerPremium: false
}
]
}
]
}
}));
// Act
const newShopTimeSlots = await wrapper.vm.getAvailableDatesMethod(
'2023-01-01',
'2023-01-15'
);
// Assert
expect(newShopTimeSlots).toStrictEqual({
// Assert
expect(arePagePrerequisitesValid).toBeFalsy();
});
test('should return newShopTimeSlots when getAvailableDatesMethod is called', async () => {
// Arrange
const { wrapper } = getShallowMountedComponent();
wrapper.vm.selectableDatesData = {
days: []
};
const store = useMainStore();
store.getShopTimeSlots.mockImplementation(() => Promise.resolve({
data: {
estimatedServiceMinutesMinimum: 90,
estimatedServiceMinutesMaximum: 120,
days: [
{
date: '2023-12-01',
timeSlots: [
{
endTime: '08:00',
id: '06747-01820-S-B*20424*7 AM',
offerPremium: false,
startTime: '07:00'
startTime: '07:00',
endTime: '08:00',
offerPremium: false
}
]
}
],
estimatedServiceMinutesMinimum: 90,
estimatedServiceMinutesMaximum: 120
});
});
test('Should call API service in days of 15 or less when getAvailableDatesMethod is called with large date ranges', async () => {
// Arrange
const { wrapper } = getShallowMountedComponent();
wrapper.vm.selectableDatesData = {
days: []
};
const store = useMainStore();
store.getShopTimeSlots.mockImplementation(() => Promise.resolve({
data: {
estimatedServiceMinutesMinimum: 90,
estimatedServiceMinutesMaximum: 120,
days: []
]
}
}));
// Act
const newShopTimeSlots = await wrapper.vm.getAvailableDatesMethod(
'2023-01-01',
'2023-01-15'
);
// Assert
expect(newShopTimeSlots).toStrictEqual({
days: [
{
date: '2023-12-01',
timeSlots: [
{
endTime: '08:00',
id: '06747-01820-S-B*20424*7 AM',
offerPremium: false,
startTime: '07:00'
}
]
}
}));
// Act
await wrapper.vm.getAvailableDates.call(
wrapper.vm,
'2023-01-01',
'2023-03-31',
'Inshop',
'123',
'43228'
);
// Assert
// 2023-01-01 --> 2023-02-05
// 2023-02-06 --> 2023-03-12
// 2023-03-13 --> 2023-03-31
expect(store.getShopTimeSlots).toHaveBeenCalledTimes(6);
],
estimatedServiceMinutesMinimum: 90,
estimatedServiceMinutesMaximum: 120
});
});
describe('Rendering', () => {
test('Schedule page loads', () => {
// Arrange
const { wrapper } = getShallowMountedComponent();
// Assert
expect(wrapper).toBeTruthy();
});
});
describe('schedule page methods...', () => {
test('getServiceZipCtuCodeFromStore should return zipCodeCtu', () => {
// Arrange
const { wrapper } = getShallowMountedComponent();
wrapper.vm.selectableDatesData = {
test('Should call API service in days of 15 or less when getAvailableDatesMethod is called with large date ranges', async () => {
// Arrange
const { wrapper } = getShallowMountedComponent();
wrapper.vm.selectableDatesData = {
days: []
};
const store = useMainStore();
store.getShopTimeSlots.mockImplementation(() => Promise.resolve({
data: {
estimatedServiceMinutesMinimum: 90,
estimatedServiceMinutesMaximum: 120,
days: []
};
}
}));
// Act
const testValue = wrapper.vm.getServiceZipCtuCodeFromStore();
// Act
await wrapper.vm.getAvailableDates.call(
wrapper.vm,
'2023-01-01',
'2023-03-31',
'Inshop',
'123',
'43228'
);
// Assert
expect(testValue).toStrictEqual('01234');
});
// Assert
// 2023-01-01 --> 2023-02-05
// 2023-02-06 --> 2023-03-12
// 2023-03-13 --> 2023-03-31
expect(store.getShopTimeSlots).toHaveBeenCalledTimes(6);
});
test.skip('forwardButtonAction should call route method navigateWithoutSaving', async () => {
});
describe('Rendering', () => {
test('Schedule page loads', () => {
// Arrange
const { wrapper } = getShallowMountedComponent();
// Assert
expect(wrapper).toBeTruthy();
});
});
describe('schedule page methods...', () => {
test('getServiceZipCtuCodeFromStore should return zipCodeCtu', () => {
// Arrange
const { wrapper } = getShallowMountedComponent();
wrapper.vm.selectableDatesData = {
days: []
};
// Act
const testValue = wrapper.vm.getServiceZipCtuCodeFromStore();
// Assert
expect(testValue).toStrictEqual('01234');
});
test('forwardButtonAction should call route method navigate', async () => {
// Arrange
const { wrapper } = getShallowMountedComponent();
wrapper.vm.$router.navigate = jest.fn(() => ({}));
wrapper.vm.mainStore.saveSchedule = jest.fn();
wrapper.vm.navigationScenarios = {
CLICKED_FORWARD: 'CLICKED_FORWARD'
};
// Mock the serviceLocation ref using Object.defineProperty to bypass readonly
Object.defineProperty(wrapper.vm.$refs, 'serviceLocation', {
value: {
forwardButtonAction: jest.fn()
},
configurable: true
});
wrapper.vm.selectedTimeSlotInfo = {
timeSlot: {
routeCode: 'test-id'
@ -281,6 +333,59 @@ describe('schedule-page.vue', () => {
// Act
await wrapper.vm.forwardButtonAction();
// Assert
expect(wrapper.vm.$router.navigate).toHaveBeenCalled();
});
test('recalAckModal should open when part requires recal, but recal not added to order', async () => {
// Arrange
const { wrapper } = getShallowMountedComponent();
wrapper.vm.mainStore.lineItems.glassParts = [
{
partNumber: 'TEST123',
requiresRecalibration: true
}
];
// Act
await wrapper.vm.forwardButtonAction();
// Assert
expect(wrapper.vm.$refs[RECAL_ACK_MODAL_REF_NAME].openModal).toHaveBeenCalled();
});
test('navigation forward is blocked when recalAckModal is displayed, but checkbox has not been acknowledged', async () => {
// Arrange
const { wrapper } = getShallowMountedComponent();
wrapper.vm.mainStore.lineItems.glassParts = [
{
partNumber: 'TEST123',
requiresRecalibration: true
}
];
// Act
await wrapper.vm.forwardButtonAction();
await wrapper.vm.$refs[RECAL_ACK_MODAL_REF_NAME].openModal();
await wrapper.vm.$refs[RECAL_ACK_MODAL_REF_NAME].footerButtonClick();
// Assert
expect(wrapper.vm.$router.navigate).not.toHaveBeenCalled();
});
test('navigation forward is allowed when checkbox has been acknowledged on recalAckModal', async () => {
// Arrange
const { wrapper } = getShallowMountedComponent();
wrapper.vm.mainStore.lineItems.glassParts = [
{
partNumber: 'TEST123',
requiresRecalibration: true
}
];
// Act
await wrapper.vm.$refs[RECAL_ACK_MODAL_REF_NAME].openModal();
wrapper.vm.updateIsRecalAcknowledged(true);
await wrapper.vm.$refs[RECAL_ACK_MODAL_REF_NAME].footerButtonClick();
await wrapper.vm.forwardButtonAction();
// Assert
expect(wrapper.vm.$router.navigate).toHaveBeenCalled();
});

View file

@ -64,6 +64,11 @@
:ref="SUGGEST_TIMESLOT_MODAL_REF_NAME"
cmsWidgetName="SuggestTimeslotModal"
@confirmAppointmentClicked="autoSelectTimeslotConfirmed" />
<recalAckModal
:ref="RECAL_ACK_MODAL_REF_NAME"
cmsWidgetName="RecalAckModalWidget"
:ackError="ackError"
@recalAcknowledged="updateIsRecalAcknowledged"/>
</Form>
</template>
<script>
@ -75,6 +80,7 @@ import datePicker from '@/digital-components/date-picker/date-picker.vue';
import serviceLocation from '@/layouts/schedule-page/service-location/service-location.vue';
import siteFooter from '@/iss-components/site-footer/site-footer.vue';
import suggestTimeslotModal from '@/layouts/schedule-page/suggest-timeslot-modal/suggest-timeslot-modal.vue';
import recalAckModal from '@/layouts/schedule-page/recal-ack-modal/recal-ack-modal.vue';
// Supporting files
import { experimentSettings, experimentTest, experimentVariation, experimentUniverses } from '@/constants/experiments';
@ -105,10 +111,13 @@ import {
import { Form } from 'vee-validate';
import BaseFormMixin from '@/mixins/base-form-mixin.js';
import { useMainStore } from '@/store';
import { containsRecalParts, anyPartWithRequiresRecalFlag } from '@/helpers/recal-helper';
import errorMessages from '@/constants/error-messages';
// Define constants
const SUGGEST_TIMESLOT_MODAL_REF_NAME = 'SuggestTimeslotModal';
const TIME_SLOTS_CALL_DAYS_LIMIT = 15;
const RECAL_ACK_MODAL_REF_NAME = 'recalAckModal';
const getAvailableDates = async (
startDateString,
@ -230,7 +239,8 @@ export default {
serviceLocation,
siteFooter,
suggestTimeslotModal,
Form
Form,
recalAckModal
},
mixins: [BaseFormMixin],
async beforeRouteEnter(to, from, next) {
@ -370,7 +380,9 @@ export default {
selectedServiceLocationCity: this.getServiceLocationCity(),
selectedTimeSlotInfo: this.getSelectedTimeSlotInfo(),
showDatePickerError: false,
SUGGEST_TIMESLOT_MODAL_REF_NAME
SUGGEST_TIMESLOT_MODAL_REF_NAME,
RECAL_ACK_MODAL_REF_NAME,
isRecalAcknowledged: this.getIsRecalAcknowledgedForScheduling()
};
},
computed: {
@ -412,6 +424,21 @@ export default {
},
supportingItems() {
return useMainStore().lineItems.supportingItems;
},
displayRecalAckModal() {
// if any of the glass parts contain an item with the requiresRecalibration flag set to true
// and the order does not contain a recalibration part, show the recalibration acknowledgement modal
const { glassParts } = useMainStore().order.lineItems;
const containsRecalPart = containsRecalParts(glassParts);
const hasPartWithRecalRequirement = anyPartWithRequiresRecalFlag(glassParts);
if (!containsRecalPart && hasPartWithRecalRequirement) {
return true;
}
return false;
},
ackError() {
return errorMessages.ACKNOWLEDGEMENT_REQUIRED;
}
},
watch: {
@ -440,6 +467,8 @@ export default {
this.mql = window.matchMedia('(min-width: 1200px)');
this.isMobileView = !this.mql.matches;
this.mql.addEventListener('change', this.handleMqlChange);
this.mainStore.order.isRecalAcknowledgedForScheduling = false;
this.isRecalAcknowledged = false;
},
unmounted() {
if (this.mql) {
@ -931,6 +960,12 @@ export default {
this.showDatePickerError = false;
},
async forwardButtonAction() {
if (this.displayRecalAckModal && this.isRecalAcknowledged === false) {
showIssLoadingModal(false);
this.$refs[RECAL_ACK_MODAL_REF_NAME].openModal();
return;
}
if (!this.isFormValid) {
this.showDatePickerError = true;
return;
@ -947,7 +982,14 @@ export default {
this.navigationScenarios.CLICKED_FORWARD,
this.$route
);
}
},
updateIsRecalAcknowledged(isAcknowledged) {
this.isRecalAcknowledged = isAcknowledged;
this.mainStore.order.isRecalAcknowledgedForScheduling = isAcknowledged;
},
getIsRecalAcknowledgedForScheduling() {
return this.mainStore.order.isRecalAcknowledgedForScheduling;
},
}
};
</script>

View file

@ -225,7 +225,8 @@ export const getDefaultState = () => ({
loadedFromDupeCheck: null,
loadedSessionClearedPreviousData: null,
availableVaps: null,
visitedDuplicateCheckPage: false
visitedDuplicateCheckPage: false,
isRecalAcknowledgedForScheduling: false
},
applicationUser: {
experiments: [],
@ -3163,7 +3164,7 @@ export const useMainStore = defineStore({
resetSubmittedOrder() {
// clear from sessionStorage
window.sessionStorage.removeItem(webStorageConstants.SUBMITTED_ORDER);
}
},
},
persist: {
// Default is localStorage, but sessionStorage is used here to ensure state is cleared when the browser tab is closed, preventing potential issues with stale data on return visits.