Merge pull request #1262 from Safelite/defect/CSR-1510

Defect/csr 1510
This commit is contained in:
Leah Schumann 2023-07-24 09:55:06 -04:00 committed by GitHub
commit a87d039599
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 222 additions and 146 deletions

View file

@ -84,6 +84,9 @@ import {
} from "@/layouts/schedule/helpers/schedule-helper";
import { useField, ErrorMessage } from "vee-validate";
import { deepClone } from "@/helpers/object-helper";
import { v4 as uuidv4 } from "uuid";
export default {
name: "datePicker",
data() {
@ -96,6 +99,7 @@ export default {
};
},
props: {
customComponentId: String,
selectableDatesSetting: {
type: String,
validator(value) {
@ -123,18 +127,24 @@ export default {
},
},
setup(props) {
const propsClone = Object.assign({}, props);
const modelValue = propsClone.modelValue;
const uuid = uuidv4();
const componentId = !props.customComponentId
? `component-${uuid}`
: props.customComponentId;
const modelValue = deepClone(props).modelValue;
const initialValue = modelValue;
const fieldOptions = {
value: modelValue,
initialValue: modelValue,
initialValue: initialValue,
};
const { errorMessage, handleBlur, handleChange, meta, validate, errors, resetField } =
useField("date-of-month", props.validationRules, fieldOptions);
useField(componentId, props.validationRules, fieldOptions);
return {
componentId,
errorMessage,
handleBlur,
handleChange,

View file

@ -11,8 +11,9 @@
class="mb-3 text-link-small"
marginTopSizeOverride="1" />
</template>
<location-alerts cmsWidgetPrefix="LocationAlert-" ref="locationAlerts" />
<date-picker
<locationAlerts cmsWidgetPrefix="LocationAlert-" ref="locationAlerts" />
<datePicker
customComponentId="dateQuestion"
selectableDatesSetting="custom"
ref="datePicker"
v-model="selectedDate"
@ -20,21 +21,23 @@
:customSelectableDatesCallback="getAvailableDatesMethod"
validationRules="date-required"
@date-clicked="openInshopTimeSlotsModal" />
<time-slot-modal-question
<timeSlotModalQuestion
ref="timeSlotModalQuestion"
customComponentId="timeSlotModalQuestion"
cmsWidgetName="TimeSlotModalQuestion"
mobilePremiumCmsWidgetName="MobilePremiumTimeSlotModal"
mobileCmsWidgetName="MobileTimeSlotModal"
dropoffCmsWidgetName="DropOffTimeSlotModal"
sameDayDropOffCmsWidgetName="SameDayDropOffTimeSlotModal"
overnightDropOffCmsWidgetName="OvernightDropOffTimeSlotModal"
v-model="selectedTimeSlotData"
@time-slot-modal-closed="timeSlotModalClosed"
v-model="selectedTimeSlot"
:selectedDate="selectedDate"
:appointmentType="appointmentType"
:premiumAppointmentFee="mobilePremiumAppointmentFee"
:dateAndTimeSlotData="timeSlotsForSelectedDate"
:estimatedServiceMinutesMinimum="selectableDatesData.estimatedServiceMinutesMinimum"
:estimatedServiceMinutesMaximum="selectableDatesData.estimatedServiceMinutesMaximum"
@time-slot-modal-closed="timeSlotModalClosed"
validationRules="time-slot-selection-required" />
<funnel-footer
cmsWidgetName="FunnelFooterWidget"
@ -75,8 +78,19 @@ import { errorMessages } from "@/constants/error-messages";
import { required } from "@/helpers/validation-rules";
import store from "@/store";
// DEFINE VALIDATION RULES
defineRule("date-required", required(errorMessages.DATE_REQUIRED));
defineRule("time-slot-selection-required", required(errorMessages.DATE_REQUIRED));
defineRule("time-slot-selection-required", (value) => {
if (
value.date == null ||
value.startTime == null ||
value.routeCode == null ||
value.estimatedServiceMinutesMaximum
) {
return errorMessages.DATE_REQUIRED;
}
return true;
});
// Define constants
const TIME_SLOTS_CALL_DAYS_LIMIT = 34; // needs to be 34 for API limits (35 does not consistently work)
@ -176,10 +190,7 @@ export default {
data() {
return {
selectedDate: this.getSelectedDate(),
selectedTimeSlotData: {
id: this.getSelectedRouteCode(),
isPremiumAppointment: this.isMobilePremiumFeeOnOrderInVuex(),
},
selectedTimeSlot: this.getSelectedTimeSlot(),
selectableDatesData: [],
mobilePremiumAppointmentFee: null,
};
@ -222,6 +233,7 @@ export default {
store.getters.order.serviceLocation.zipCodeCtu,
store.getters.order.serviceLocation.provider?.address?.zipCodeCtu
);
// Settle promises and get results
const promiseResultMap = [
{
@ -253,7 +265,7 @@ export default {
vm.mobilePremiumAppointmentFee = resultMap.premiumFeeWithPrice
? resultMap.premiumFeeWithPrice[0]
: null;
vm.updateFooterButtonText(vm.selectedTimeSlotData);
vm.updateFooterButtonText(vm.selectedTimeSlot);
});
},
computed: {
@ -271,30 +283,11 @@ export default {
if (!this.selectedDate) {
return null;
}
return this.selectableDatesData.days?.find(
(selectableDate) => selectableDate.date === this.selectedDate
);
},
appointmentDateAndTime() {
if (!this.selectedTimeSlotData.id) {
return null;
}
const timeSlotSelectedObject = this.getTimeSlotObjectFromTimeSlotId(
this.selectedTimeSlotData.id
);
if (timeSlotSelectedObject) {
return {
date: this.selectedDate,
startTime: timeSlotSelectedObject.startTime,
endTime: timeSlotSelectedObject.endTime,
routeCode: this.selectedTimeSlotData.id,
jobMaxMinutes:
this.selectableDatesData.estimatedServiceMinutesMaximum.toString(),
};
} else {
return null;
}
},
},
methods: {
splitCopyOnCMSPlaceHolder,
@ -333,11 +326,8 @@ export default {
openInshopTimeSlotsModal() {
this.$refs["timeSlotModalQuestion"].openModal();
},
getTimeSlotObjectFromTimeSlotId(timeSlotId) {
const timeSlots = this.selectableDatesData.days.find(
(selectableDate) => selectableDate.date === this.selectedDate
)?.timeSlots;
if (timeSlots) return timeSlots.find((timeSlot) => timeSlot.id === timeSlotId);
getSelectedTimeSlot() {
return store.getters.order.schedule;
},
getSelectedDate() {
return store.getters.order.schedule.date;
@ -345,6 +335,9 @@ export default {
getSelectedRouteCode() {
return store.getters.order.schedule.routeCode;
},
getSupportingItems() {
return store.getters.lineItems.supportingItems;
},
isMobilePremiumFeeOnOrderInVuex() {
const supportingItemsFromVuex = store.getters.lineItems.supportingItems;
return !!supportingItemsFromVuex.filter(
@ -353,37 +346,31 @@ export default {
},
timeSlotModalClosed() {
// Clear the selectedDate if no timeSlot has been selected
if (!this.selectedTimeSlotData.id) {
if (this.selectedTimeSlot.routeCode == null) {
this.selectedDate = null;
}
},
updateFooterButtonText(timeSlotData) {
updateFooterButtonText(timeSlot) {
let funnelFooterButtonText;
if (!timeSlotData.id || !this.appointmentDateAndTime) {
if (!timeSlot || !timeSlot.date) {
funnelFooterButtonText = "Continue";
} else {
funnelFooterButtonText =
"Select " + this.convertSelectedDateToShortMonthAndDay(this.selectedDate);
funnelFooterButtonText = `Select ${this.convertSelectedDateToShortMonthAndDay(
timeSlot.date
)}`;
if (this.appointmentType === AppointmentTypeStrings.IN_SHOP) {
funnelFooterButtonText +=
" at " +
this.getDisplayTextForMilitaryTime(this.appointmentDateAndTime.startTime);
funnelFooterButtonText += ` at ${this.getDisplayTextForMilitaryTime(
timeSlot.startTime
)}`;
} else if (
this.appointmentType === AppointmentTypeStrings.MOBILE &&
!timeSlotData.isPremiumAppointment
!timeSlot.isPremiumAppointment
) {
funnelFooterButtonText +=
" at " +
this.getDisplayTextForMilitaryTime(
this.appointmentDateAndTime.startTime,
true
) +
" - " +
this.getDisplayTextForMilitaryTime(
this.appointmentDateAndTime.endTime,
true
);
funnelFooterButtonText += ` at ${this.getDisplayTextForMilitaryTime(
timeSlot.startTime,
true
)} - ${this.getDisplayTextForMilitaryTime(timeSlot.endTime, true)}`;
}
}
this.$refs.funnelFooter.updateButtonText(funnelFooterButtonText);
@ -413,25 +400,21 @@ export default {
},
forwardButtonAction() {
this.updateSupportingItems();
this.dispatchStoreAction(
this.storeActions.SAVE_SCHEDULE,
this.appointmentDateAndTime,
false
);
this.dispatchStoreAction(this.storeActions.SAVE_SCHEDULE, this.selectedTimeSlot, false);
//Temporary, still need to determine what needs to be saved before continuing.
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_FORWARD,
this.$route
);
},
updateSupportingItems() {
const supportingItems = store.getters.lineItems.supportingItems;
const supportingItems = this.getSupportingItems();
// if we have a premium fee(early bird), then save/update supporting items
if (
this.appointmentType === AppointmentTypeStrings.MOBILE &&
this.selectedTimeSlotData?.isPremiumAppointment
this.selectedTimeSlot?.isPremiumAppointment
) {
const earlyBirdIndex = supportingItems.findIndex(
(item) => item.partType == PREMIUM_FEE_PART_TYPE
@ -474,14 +457,17 @@ export default {
selectedDate(newValue, oldValue) {
// Clear time slot selection if date selected changes
if (newValue !== oldValue) {
this.selectedTimeSlotData = {
id: null,
isPremiumAppointment: null,
this.selectedTimeSlot = {
date: null,
routeCode: null,
startTime: null,
endTime: null,
jobMaxMinutes: null,
};
}
},
selectedTimeSlotData(newValue) {
this.updateFooterButtonText(this.selectedTimeSlotData);
selectedTimeSlot(newValue) {
this.updateFooterButtonText(newValue);
},
},
components: {

View file

@ -1,38 +1,41 @@
<template>
<modal
ref="timeSlots"
class="time-slots-modal"
:ref="modalName"
:headerText="dateSelectedReadableDate"
:footerButtonText="footerCloseButtonText"
:onModalOpenedCallback="onModalOpened"
:onModalClosedCallback="onModalClosed"
@footer-button-event="closeModal">
<textBlock
v-show="durationTextBlockCopy"
:customText="durationTextBlockCopy"
justifyText="center"
typeStyle="small"
class="duration-text-block" />
<buttonQuestion
buttonTypeString="timeSlotModalListButton"
:buttonTypeObject="timeSlotModalListButton"
:answers="availableTimeSlots"
groupName="ChooseTimeSlot"
textPosition="text-center"
v-model="selectedTimeSlotId"
isRequired
validationRules="time-slot-required"
class="mt-5" />
<div
class="mt-1 mb-2 supplemental-information"
v-if="supplementalInformationBlock"
v-html="supplementalInformationBlock"></div>
<textBlock
v-show="disclaimerTextBlockCopy"
:customText="disclaimerTextBlockCopy"
justifyText="left"
typeStyle="caption"
class="mb-2" />
@isModalOpened="setModalStatus"
@footer-button-event="setSelectedTimeSlot"
class="time-slots-modal">
<template v-if="isModalOpened">
<textBlock
v-show="durationTextBlockCopy"
:customText="durationTextBlockCopy"
justifyText="center"
typeStyle="small"
class="duration-text-block" />
<buttonQuestion
ref="buttonQuestion"
buttonTypeString="timeSlotModalListButton"
:buttonTypeObject="timeSlotModalListButton"
class="mt-5"
:answers="availableTimeSlots"
groupName="chooseTimeSlot"
textPosition="text-center"
v-model="selectedTimeSlotId"
isRequired
validationRules="time-slot-required" />
<div
class="mt-1 mb-2 supplemental-information"
v-if="supplementalInformationBlock"
v-html="supplementalInformationBlock"></div>
<textBlock
v-show="disclaimerTextBlockCopy"
:customText="disclaimerTextBlockCopy"
justifyText="left"
typeStyle="caption"
class="mb-2" />
</template>
</modal>
</template>
@ -41,21 +44,27 @@
import modal from "@/digital-components/modal/modal";
import textBlock from "@/digital-components/text-block/text-block";
import buttonQuestion from "@/digital-components/button-question/button-question";
import { convertDateStringToDate } from "@/layouts/schedule/helpers/schedule-helper";
import timeSlotModalListButton from "./time-slot-modal-list-button/time-slot-modal-list-button";
// TODO: Move this somewhere more global
// Helpers
import { convertDateStringToDate } from "@/layouts/schedule/helpers/schedule-helper";
import { deepClone } from "@/helpers/object-helper";
// Validation - TODO: Move this somewhere more global?
import { defineRule, useField } from "vee-validate";
import { errorMessages } from "@/constants/error-messages";
import { required } from "@/helpers/validation-rules";
import { v4 as uuidv4 } from "uuid";
// Constants
import {
AppointmentTypeStrings,
RouteCodeFlags,
PREMIUM_TIME_SLOT_ID_FLAG,
PREMIUM_FEE_PART_TYPE,
RouteCodeFlags,
} from "@/constants/schedule-constants";
const cmsWidgetFieldMappings = {
MODAL_CLOSE_BUTTON: "FooterText",
SUPPLEMENTAL_INFORMATION: "BodyText",
@ -67,12 +76,20 @@ const cmsWidgetFieldMappings = {
// Validation for the modal button
defineRule("time-slot-required", required(errorMessages.OPTION_REQUIRED));
// Constants
export default {
name: "timeSlotModalQuestion",
name: "time-slot-modal-question",
emits: ["update:modelValue"],
props: {
modelValue: Object,
modelValue: {
type: Object,
default: () => ({
routeCode: null,
date: null,
startTime: null,
endTime: null,
jobMaxMinutes: null,
}),
},
cmsWidgetName: String,
mobileCmsWidgetName: String,
mobilePremiumCmsWidgetName: String,
@ -85,32 +102,58 @@ export default {
estimatedServiceMinutesMinimum: Number,
estimatedServiceMinutesMaximum: Number,
validationRules: String,
customComponentId: String,
selectedDate: String,
},
data() {
return {
selectedTimeSlotId: this.getModifiedSelectedTimeSlotId(),
isModalOpened: false,
selectedValue: null,
timeSlotModalListButton: timeSlotModalListButton,
};
},
setup(props) {
const { handleChange } = useField("time-slot-modal-question", props.validationRules);
// Run validation on component load
handleChange(props.modelValue.id);
const uuid = uuidv4();
const componentId = !props.customComponentId
? `component-${uuid}`
: props.customComponentId;
const modelValue = deepClone(props).modelValue;
const initialValue = modelValue;
const fieldOptions = {
value: modelValue,
initialValue: initialValue,
};
const { errorMessage, handleChange, meta, validate, errors } = useField(
componentId,
props.validationRules,
fieldOptions
);
return {
componentId,
errorMessage,
handleChange,
validate,
meta,
errors,
};
},
watch: {
modelValue() {
this.selectedTimeSlotId = this.getModifiedSelectedTimeSlotId();
// Run component validation that is used at parent level
this.handleChange(this.modelValue.id);
},
availableTimeSlots(newValue) {
this.autoSelectTimeSlotIfOnlyOneIsAvailable(newValue);
},
},
computed: {
modalName() {
return "timeSlots";
},
selectedTimeSlotId: {
get: function () {
return this.modelValue?.routeCode;
},
set: function (newValue) {
// Button Question only supports Number, or String data types so we must get the full object to emit
this.selectedValue = this.getSelectedTimeSlotObject(newValue);
},
},
supplementalInformationBlock() {
let appointmentTypeCmsWidgetName;
if (this.appointmentType === AppointmentTypeStrings.IN_SHOP) {
@ -219,10 +262,12 @@ export default {
this.cmsWidgetName,
cmsWidgetFieldMappings.DURATION
);
const inshopDurationTime = this.getDisplayTextForDurationLength(
this.estimatedServiceMinutesMinimum,
this.estimatedServiceMinutesMaximum
);
return `${inshopDurationTextWithoutTime} ${inshopDurationTime}`;
},
durationTextBlockCopy() {
@ -248,6 +293,7 @@ export default {
if (!this.dateAndTimeSlotData) {
return false;
}
const selectedDate = this.dateAndTimeSlotData.date;
const todaysDate = new Date().toISOString().split("T")[0];
return selectedDate === todaysDate;
@ -270,6 +316,7 @@ export default {
if (!this.dateAndTimeSlotData) {
return null;
}
if (this.appointmentType === AppointmentTypeStrings.DROP_OFF) {
return this.getAvailableTimeSlotsForDropOff(this.dateAndTimeSlotData.timeSlots);
} else if (this.appointmentType === AppointmentTypeStrings.MOBILE) {
@ -278,44 +325,37 @@ export default {
return this.getAvailableTimeSlotsForInshop(this.dateAndTimeSlotData.timeSlots);
}
},
modal() {
return this.$refs["timeSlots"];
},
},
methods: {
openModal() {
this.$refs["timeSlots"].openModal();
this.modal.openModal();
},
setModalStatus(isOpened) {
this.isModalOpened = isOpened;
},
// fires any time the footer button is used, is fired before "onModalClosed"
closeModal() {
let isSelectedAppointmentPremium = false;
if (this.selectedTimeSlotId.includes(PREMIUM_TIME_SLOT_ID_FLAG)) {
this.selectedTimeSlotId = this.removePremiumFlagFromInput(this.selectedTimeSlotId);
isSelectedAppointmentPremium = true;
}
const selectedTimeSlotData = {
id: this.selectedTimeSlotId,
isPremiumAppointment: isSelectedAppointmentPremium,
};
this.$emit("update:modelValue", selectedTimeSlotData);
this.$refs["timeSlots"].closeModal();
this.modal.closeModal();
},
// fires any time the modal is closed, AFTER "closeModal" fires if footer button is used
onModalClosed() {
this.$emit("time-slot-modal-closed");
},
getModifiedSelectedTimeSlotId() {
if (this.modelValue.isPremiumAppointment) {
return this.addPremiumFlagToInput(this.modelValue.id);
} else {
return this.modelValue.id;
}
async setSelectedTimeSlot() {
this.$emit("update:modelValue", this.selectedValue);
this.closeModal();
},
// Expected input: "HH:MM"
getDisplayTextForMilitaryTime(militaryTimeInput) {
// Expected input: "HH:MM"
let hours = parseInt(militaryTimeInput.split(":")[0]);
const minutes = militaryTimeInput.split(":")[1];
const meridianNotation = hours > 11 ? "PM" : "AM";
if (hours > 12) {
hours -= 12;
}
return `${hours}:${minutes} ${meridianNotation}`;
},
getDisplayTextForDurationLength(durationMinimum, durationMaximum) {
@ -364,11 +404,13 @@ export default {
} else {
buttonLabelValue = this.dropoffButtonText;
}
return {
value: timeSlot.id,
buttonLabel: buttonLabelValue,
};
});
return availableTimeSlots;
},
getAvailableTimeSlotsForMobile(timeSlotsForSelectedDate) {
@ -418,6 +460,45 @@ export default {
removePremiumFlagFromInput(timeSlotId) {
return timeSlotId.substring(0, timeSlotId.length - PREMIUM_TIME_SLOT_ID_FLAG.length);
},
getSelectedTimeSlotObject(timeSlotId) {
const timeSlot = this.dateAndTimeSlotData?.timeSlots?.find(
(timeSlot) => timeSlot.id == timeSlotId
);
if (timeSlot) {
return {
date: this.dateAndTimeSlotData.date,
routeCode: timeSlot.id,
startTime: timeSlot.startTime,
endTime: timeSlot.endTime,
jobMaxMinutes: this.estimatedServiceMinutesMaximum.toString(),
};
}
return {
date: null,
startTime: null,
endTime: null,
routeCode: null,
jobMaxMinutes: null,
};
},
},
watch: {
modelValue: {
handler(newValue) {
this.handleChange(newValue);
},
deep: true,
},
selectedDate: {
handler() {
this.selectedTimeSlotId = null;
},
},
availableTimeSlots(newValue) {
this.autoSelectTimeSlotIfOnlyOneIsAvailable(newValue);
},
},
components: {
modal,

View file

@ -265,7 +265,6 @@ export default {
modelValue: {
handler(newValue) {
this.internalModel = deepClone(newValue);
this.handleChange(newValue);
},
deep: true,