Merge pull request #3236 from Safelite/feature/CASH-2880
CASH-2880 | Loading animation and blocking overlay for scheduling
This commit is contained in:
commit
d32794aff4
10 changed files with 306 additions and 5 deletions
|
|
@ -243,4 +243,27 @@ describe("inshop-scheduling-card.vue", () => {
|
|||
expect(wrapper.find(".inshop-scheduling-card__body").isVisible()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
describe("isLoading prop", () => {
|
||||
test("shows the skeleton loader and hides real content when isLoading is true", () => {
|
||||
const wrapper = mountComponent({ isLoading: true });
|
||||
expect(wrapper.find("scheduling-card-loader-stub").exists()).toBe(true);
|
||||
expect(wrapper.find(".inshop-scheduling-card__header").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("hides the skeleton loader and shows real content when isLoading is false", () => {
|
||||
const wrapper = mountComponent({ isLoading: false });
|
||||
expect(wrapper.find("scheduling-card-loader-stub").exists()).toBe(false);
|
||||
expect(wrapper.find(".inshop-scheduling-card__header").exists()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("defaults isLoading to false", () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.props("isLoading")).toBe(false);
|
||||
expect(wrapper.find("scheduling-card-loader-stub").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
<template>
|
||||
<div class="inshop-scheduling-card">
|
||||
<div class="inshop-scheduling-card__panel">
|
||||
<div v-if="isLoading" class="inshop-scheduling-card__panel">
|
||||
<schedulingCardLoader />
|
||||
</div>
|
||||
<div v-else class="inshop-scheduling-card__panel">
|
||||
<button
|
||||
type="button"
|
||||
class="inshop-scheduling-card__header"
|
||||
|
|
@ -113,6 +116,7 @@ import {
|
|||
INSHOP_INITIAL_VISIBLE_TIME_SLOTS,
|
||||
mapInshopTimeSlotToDisplaySlot,
|
||||
} from "@/layouts/schedule/helpers/schedule-helper";
|
||||
import schedulingCardLoader from "@/layouts/scheduling/scheduling-card-loader/scheduling-card-loader";
|
||||
|
||||
function toTitleCase(str) {
|
||||
if (!str) return "";
|
||||
|
|
@ -155,6 +159,10 @@ export default {
|
|||
type: String,
|
||||
default: "DropoffQuestionWidget",
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -317,6 +325,9 @@ export default {
|
|||
});
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schedulingCardLoader,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -167,4 +167,33 @@ describe("mobile-scheduling-card.vue", () => {
|
|||
expect(wrapper.find(".mobile-scheduling-card__body").isVisible()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
describe("isLoading prop", () => {
|
||||
test("shows the skeleton loader and hides real content when isLoading is true", () => {
|
||||
const wrapper = mountComponent({ isLoading: true });
|
||||
expect(wrapper.find("scheduling-card-loader-stub").exists()).toBe(true);
|
||||
expect(wrapper.find(".mobile-scheduling-card__header").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("hides the skeleton loader and shows real content when isLoading is false", () => {
|
||||
const wrapper = mountComponent({ isLoading: false });
|
||||
expect(wrapper.find("scheduling-card-loader-stub").exists()).toBe(false);
|
||||
expect(wrapper.find(".mobile-scheduling-card__header").exists()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("hides the free flag when isLoading is true even if showFreeFlag is true", () => {
|
||||
const wrapper = mountComponent({ isLoading: true, showFreeFlag: true });
|
||||
expect(wrapper.find(".mobile-scheduling-card__free-flag").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("defaults isLoading to false", () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.props("isLoading")).toBe(false);
|
||||
expect(wrapper.find("scheduling-card-loader-stub").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="mobile-scheduling-card">
|
||||
<div v-if="showFreeFlag" class="mobile-scheduling-card__free-flag">
|
||||
<div v-if="showFreeFlag && !isLoading" class="mobile-scheduling-card__free-flag">
|
||||
<img
|
||||
class="mobile-scheduling-card__free-flag-icon"
|
||||
src="@/assets/img/party.svg"
|
||||
|
|
@ -8,7 +8,11 @@
|
|||
aria-hidden="true" />
|
||||
<span class="mobile-scheduling-card__free-flag-text">{{ freeFlagText }}</span>
|
||||
</div>
|
||||
<div v-if="isLoading" class="mobile-scheduling-card__panel">
|
||||
<schedulingCardLoader />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="mobile-scheduling-card__panel"
|
||||
:class="{ 'mobile-scheduling-card__panel--with-free-flag': showFreeFlag }">
|
||||
<button
|
||||
|
|
@ -86,7 +90,7 @@
|
|||
<script>
|
||||
import { PREMIUM_TIME_SLOT_ID_FLAG, AppointmentTypeStrings } from "@/constants/schedule-constants";
|
||||
import { militaryToTwelveHourTime } from "@/layouts/schedule/helpers/schedule-helper";
|
||||
|
||||
import schedulingCardLoader from "@/layouts/scheduling/scheduling-card-loader/scheduling-card-loader.vue";
|
||||
export default {
|
||||
name: "mobile-scheduling-card",
|
||||
emits: ["update:modelValue", "zip-code-clicked"],
|
||||
|
|
@ -123,6 +127,10 @@ export default {
|
|||
type: String,
|
||||
default: "MobileCardWidget",
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -228,6 +236,9 @@ export default {
|
|||
return routeCode + PREMIUM_TIME_SLOT_ID_FLAG;
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schedulingCardLoader,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { shallowMount } from "@vue/test-utils";
|
||||
import schedulingCardLoader from "./scheduling-card-loader";
|
||||
|
||||
describe("scheduling-card-loader.vue", () => {
|
||||
test("renders the loader container with aria-hidden", () => {
|
||||
const wrapper = shallowMount(schedulingCardLoader);
|
||||
expect(wrapper.find(".scheduling-card-loader").exists()).toBe(true);
|
||||
expect(wrapper.find(".scheduling-card-loader").attributes("aria-hidden")).toBe("true");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("renders exactly two skeleton rows", () => {
|
||||
const wrapper = shallowMount(schedulingCardLoader);
|
||||
expect(wrapper.findAll(".scheduling-card-loader__row").length).toBe(2);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("each row contains a wide shimmer bar", () => {
|
||||
const wrapper = shallowMount(schedulingCardLoader);
|
||||
wrapper.findAll(".scheduling-card-loader__row").forEach((row) => {
|
||||
expect(row.find(".scheduling-card-loader__bar--wide").exists()).toBe(true);
|
||||
});
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<template>
|
||||
<div class="scheduling-card-loader" aria-hidden="true">
|
||||
<div class="scheduling-card-loader__row">
|
||||
<span class="scheduling-card-loader__bar scheduling-card-loader__bar--wide"></span>
|
||||
</div>
|
||||
<div class="scheduling-card-loader__row">
|
||||
<span class="scheduling-card-loader__bar scheduling-card-loader__bar--wide"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "scheduling-card-loader",
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.scheduling-card-loader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, $gray-200 25%, $gray-100 50%, $gray-200 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: scheduling-card-shimmer 1.5s ease-in-out infinite;
|
||||
|
||||
&--wide {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scheduling-card-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
110
src/layouts/scheduling/scheduling.spec.js
Normal file
110
src/layouts/scheduling/scheduling.spec.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { shallowMount } from "@vue/test-utils";
|
||||
import scheduling from "./scheduling";
|
||||
import { getMountOptions } from "@/helpers/unit-test-helper.js";
|
||||
import { settleAllPromises } from "@/helpers/layout-helper";
|
||||
|
||||
jest.mock("@/store", () => ({
|
||||
dispatch: jest.fn().mockResolvedValue(null),
|
||||
getters: {
|
||||
order: {
|
||||
serviceLocation: { zipCode: "43235", appointmentType: null },
|
||||
payment: { isInsurance: false, insuranceCoverage: { isVerified: false } },
|
||||
referralNumber: "",
|
||||
damage: { isRepair: false },
|
||||
lineItems: { glassParts: [] },
|
||||
policy: { isItac: false, isNoComp: false },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/helpers/layout-helper", () => ({
|
||||
settleAllPromises: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
jest.mock("@/helpers/cms-content-helper", () => ({
|
||||
fetchCmsContentForPage: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
jest.mock("@/helpers/heritage-integration/navigation-helper", () => ({
|
||||
navigateToHeritageFunnel: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/helpers/debug-log-helper", () => ({
|
||||
debugLog: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/helpers/page-prerequisites-helper.js", () => ({
|
||||
flushPagePrereqsLogs: jest.fn(),
|
||||
hasServiceZipInfo: jest.fn(() => true),
|
||||
hasGlassPartsOrRepairInfo: jest.fn(() => true),
|
||||
hasInsuranceInfo: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
function setupMocks() {
|
||||
const baseMixin = {
|
||||
methods: {
|
||||
getCmsContent: jest.fn(() => ""),
|
||||
setCmsContent: jest.fn(),
|
||||
getTotalLineItemPrice: jest.fn(() => 0),
|
||||
},
|
||||
};
|
||||
const mountOptions = getMountOptions({
|
||||
route: { name: "scheduling" },
|
||||
router: {
|
||||
navigate: jest.fn(),
|
||||
navigateWithSaving: jest.fn(),
|
||||
navigateWithoutSaving: jest.fn(),
|
||||
},
|
||||
});
|
||||
mountOptions.global.mixins = [baseMixin];
|
||||
const wrapper = shallowMount(scheduling, mountOptions);
|
||||
return { wrapper };
|
||||
}
|
||||
|
||||
describe("scheduling.vue", () => {
|
||||
describe("intercept overlay", () => {
|
||||
test("does not render interceptOverlay when isLoadingDates is false", () => {
|
||||
const { wrapper } = setupMocks();
|
||||
expect(wrapper.find("intercept-overlay-stub").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("renders interceptOverlay when isLoadingDates is true", async () => {
|
||||
const { wrapper } = setupMocks();
|
||||
await wrapper.setData({ isLoadingDates: true });
|
||||
expect(wrapper.find("intercept-overlay-stub").exists()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleRequestMoreDates", () => {
|
||||
test("sets isLoadingDates to true while fetching and false after resolving", async () => {
|
||||
let resolve;
|
||||
settleAllPromises.mockReturnValueOnce(
|
||||
new Promise((r) => {
|
||||
resolve = r;
|
||||
})
|
||||
);
|
||||
|
||||
const { wrapper } = setupMocks();
|
||||
const fetchPromise = wrapper.vm.handleRequestMoreDates();
|
||||
|
||||
expect(wrapper.vm.isLoadingDates).toBe(true);
|
||||
|
||||
resolve({});
|
||||
await fetchPromise;
|
||||
|
||||
expect(wrapper.vm.isLoadingDates).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("completes without invoking loadingModal methods (stub has none)", async () => {
|
||||
// The loadingModal stub rendered by shallowMount has no showModal/hideModal.
|
||||
// If those calls were still in handleRequestMoreDates they would throw here.
|
||||
settleAllPromises.mockResolvedValueOnce({});
|
||||
const { wrapper } = setupMocks();
|
||||
await expect(wrapper.vm.handleRequestMoreDates()).resolves.toBeUndefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<template>
|
||||
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm">
|
||||
<interceptOverlay v-if="isLoadingDates" />
|
||||
<loadingModal notFullScreen ref="loadingModal" />
|
||||
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
|
||||
<div class="container page-container-grouped-styles">
|
||||
|
|
@ -29,6 +30,7 @@
|
|||
:zipCode="serviceZipCode"
|
||||
:showFreeFlag="showMobileFreeFlag"
|
||||
:radioGroupName="schedulingRadioGroupName"
|
||||
:isLoading="isLoadingDates"
|
||||
@zip-code-clicked="onMobileZipCodeClicked" />
|
||||
<inshopSchedulingCard
|
||||
v-for="{ provider } in inShopProvidersAndTimeslots"
|
||||
|
|
@ -39,6 +41,7 @@
|
|||
:provider="provider"
|
||||
:timeSlots="getInshopTimeSlotsForSelectedDate(provider.providerNumber)"
|
||||
:radioGroupName="schedulingRadioGroupName"
|
||||
:isLoading="isLoadingDates"
|
||||
@address-clicked="onInshopAddressClicked(provider)" />
|
||||
<navbar
|
||||
cmsWidgetName="FunnelFooterWidget"
|
||||
|
|
@ -60,6 +63,7 @@ import datePicker from "@/layouts/scheduling/date-picker/date-picker";
|
|||
import mobileSchedulingCard from "@/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card";
|
||||
import inshopSchedulingCard from "@/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card";
|
||||
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
|
||||
import interceptOverlay from "@/ux-components/intercept-overlay/intercept-overlay";
|
||||
import store from "@/store";
|
||||
import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants";
|
||||
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
|
||||
|
|
@ -308,7 +312,6 @@ export default {
|
|||
},
|
||||
async handleRequestMoreDates() {
|
||||
this.isLoadingDates = true;
|
||||
this.$refs.loadingModal.showModal();
|
||||
|
||||
const newStartDate = toDateString(1, this.datePickerEndDate);
|
||||
const newEndDate = toDateString(SCHEDULE_FETCH_DAYS, this.datePickerEndDate);
|
||||
|
|
@ -351,7 +354,6 @@ export default {
|
|||
|
||||
this.datePickerEndDate = newEndDate;
|
||||
this.isLoadingDates = false;
|
||||
this.$refs.loadingModal.hideModal();
|
||||
},
|
||||
forwardButtonAction() {
|
||||
const appointmentType = store.getters.order.serviceLocation.appointmentType; // TODO: remove this once we have a proper appointment type
|
||||
|
|
@ -377,6 +379,7 @@ export default {
|
|||
mobileSchedulingCard,
|
||||
inshopSchedulingCard,
|
||||
loadingModal,
|
||||
interceptOverlay,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import { shallowMount } from "@vue/test-utils";
|
||||
import interceptOverlay from "./intercept-overlay";
|
||||
|
||||
describe("intercept-overlay.vue", () => {
|
||||
test("renders the overlay container with aria-hidden", () => {
|
||||
const wrapper = shallowMount(interceptOverlay);
|
||||
expect(wrapper.find(".intercept-overlay").exists()).toBe(true);
|
||||
expect(wrapper.find(".intercept-overlay").attributes("aria-hidden")).toBe("true");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test("renders as a single root element with no visible content", () => {
|
||||
const wrapper = shallowMount(interceptOverlay);
|
||||
expect(wrapper.text()).toBe("");
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
21
src/ux-components/intercept-overlay/intercept-overlay.vue
Normal file
21
src/ux-components/intercept-overlay/intercept-overlay.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<template>
|
||||
<div class="intercept-overlay" aria-hidden="true"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "intercept-overlay",
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.intercept-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue