Merge remote-tracking branch 'origin/release/2026.07.16' into rlsmerge/2026.07.16-to-develop
This commit is contained in:
commit
b42b9edc74
16 changed files with 1039 additions and 71 deletions
|
|
@ -112,10 +112,18 @@ const endpoints = {
|
||||||
url: "/schedule/api/v1/schedule/shop-time-slots",
|
url: "/schedule/api/v1/schedule/shop-time-slots",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
},
|
},
|
||||||
|
GetShopTimeSlotsV2: {
|
||||||
|
url: "/schedule/api/v2/schedule/shop-time-slots",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
GetMobileTimeSlots: {
|
GetMobileTimeSlots: {
|
||||||
url: "/schedule/api/v1/schedule/mobile-time-slots",
|
url: "/schedule/api/v1/schedule/mobile-time-slots",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
},
|
},
|
||||||
|
GetMobileTimeSlotsV2: {
|
||||||
|
url: "/schedule/api/v2/schedule/mobile-time-slots",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
GetMobilePremiumFee: {
|
GetMobilePremiumFee: {
|
||||||
url: "/parts/api/v1/parts/mobile-premium-fee",
|
url: "/parts/api/v1/parts/mobile-premium-fee",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,9 @@ const errorMessages = {
|
||||||
INSURANCE_COMPANY_REQUIRED: "Please select an insurance company",
|
INSURANCE_COMPANY_REQUIRED: "Please select an insurance company",
|
||||||
INSURANCE_COMPANY_NAME_REQUIRED: "Please enter your insurance company name",
|
INSURANCE_COMPANY_NAME_REQUIRED: "Please enter your insurance company name",
|
||||||
POLICY_NUMBER_REQUIRED: "Please enter your policy number",
|
POLICY_NUMBER_REQUIRED: "Please enter your policy number",
|
||||||
|
POLICY_NUMBER_FORMAT: "Please enter a valid policy number",
|
||||||
|
CLAIM_NUMBER_VALID: "Please enter a valid claim number",
|
||||||
|
CLAIM_NUMBER_FORMAT: "Claim number must be 6 to 8 digits",
|
||||||
DATE_OF_LOSS_REQUIRED: "Please enter your date of damage",
|
DATE_OF_LOSS_REQUIRED: "Please enter your date of damage",
|
||||||
DAMAGE_TYPES_REQUIRED: "How damage occurred is required",
|
DAMAGE_TYPES_REQUIRED: "How damage occurred is required",
|
||||||
ZIP_CODE_REQUIRED: "Please enter your ZIP code",
|
ZIP_CODE_REQUIRED: "Please enter your ZIP code",
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,9 @@ const storeActions = {
|
||||||
GET_PRICING_BY_DAY_PART: "getPricingByDayPart",
|
GET_PRICING_BY_DAY_PART: "getPricingByDayPart",
|
||||||
GET_SERVICEABILITY_DETAILS: "getServiceabilityDetails",
|
GET_SERVICEABILITY_DETAILS: "getServiceabilityDetails",
|
||||||
GET_SHOP_TIME_SLOTS: "getShopTimeSlots",
|
GET_SHOP_TIME_SLOTS: "getShopTimeSlots",
|
||||||
|
GET_SHOP_TIME_SLOTS_V2: "getShopTimeSlotsV2",
|
||||||
GET_MOBILE_TIME_SLOTS: "getMobileTimeSlots",
|
GET_MOBILE_TIME_SLOTS: "getMobileTimeSlots",
|
||||||
|
GET_MOBILE_TIME_SLOTS_V2: "getMobileTimeSlotsV2",
|
||||||
GET_PROVIDERS: "getProviders",
|
GET_PROVIDERS: "getProviders",
|
||||||
GET_MOBILE_PREMIUM_FEE: "getMobilePremiumFee",
|
GET_MOBILE_PREMIUM_FEE: "getMobilePremiumFee",
|
||||||
SAVE_SESSION: "saveSession",
|
SAVE_SESSION: "saveSession",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { shallowMount } from "@vue/test-utils";
|
import { shallowMount } from "@vue/test-utils";
|
||||||
|
import { defineRule } from "vee-validate";
|
||||||
import textboxQuestion from "./textbox-question";
|
import textboxQuestion from "./textbox-question";
|
||||||
|
|
||||||
// Mock CMS content
|
// Mock CMS content
|
||||||
|
|
@ -12,6 +13,11 @@ const mockMixin = {
|
||||||
};
|
};
|
||||||
const maska = jest.fn();
|
const maska = jest.fn();
|
||||||
|
|
||||||
|
// Real rules registered with vee-validate so the watcher's pre-check (which calls the
|
||||||
|
// standalone `validate` export, not `this.validate`) exercises genuine valid/invalid paths.
|
||||||
|
defineRule("spec-always-valid", () => true);
|
||||||
|
defineRule("spec-always-invalid", () => "invalid");
|
||||||
|
|
||||||
describe("textboxQuestion.vue", () => {
|
describe("textboxQuestion.vue", () => {
|
||||||
it("Should coerce non-string modelValue to empty string for v-model.trim", async () => {
|
it("Should coerce non-string modelValue to empty string for v-model.trim", async () => {
|
||||||
const wrapper = shallowMount(textboxQuestion, {
|
const wrapper = shallowMount(textboxQuestion, {
|
||||||
|
|
@ -148,7 +154,7 @@ describe("textboxQuestion.vue", () => {
|
||||||
expect(wrapper.emitted()).toHaveProperty("change");
|
expect(wrapper.emitted()).toHaveProperty("change");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Should call this.handleChange with new value when the value is changed and the new value is valid", async () => {
|
it("Should call this.handleChange with new value and shouldValidate=true when the value is changed and the new value is valid", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const wrapper = shallowMount(textboxQuestion, {
|
const wrapper = shallowMount(textboxQuestion, {
|
||||||
global: {
|
global: {
|
||||||
|
|
@ -157,22 +163,43 @@ describe("textboxQuestion.vue", () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
propsData: {
|
propsData: {
|
||||||
options: {},
|
|
||||||
modelValue: "foo",
|
modelValue: "foo",
|
||||||
|
validationRules: "spec-always-valid",
|
||||||
},
|
},
|
||||||
mixins: [mockMixin],
|
mixins: [mockMixin],
|
||||||
});
|
});
|
||||||
|
|
||||||
wrapper.vm.handleChange = jest.fn().mockImplementation(() => {});
|
wrapper.vm.handleChange = jest.fn().mockImplementation(() => {});
|
||||||
wrapper.vm.validate = jest.fn().mockImplementation(() => {
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
wrapper.vm.$options.watch.value.call(wrapper.vm, "bar");
|
await wrapper.vm.$options.watch.value.call(wrapper.vm, "bar");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(wrapper.vm.handleChange).toHaveBeenCalled;
|
expect(wrapper.vm.handleChange).toHaveBeenCalledWith("bar", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Should still call this.handleChange with shouldValidate=false when the new value is invalid, so the field stays in sync for later validation", async () => {
|
||||||
|
// Arrange
|
||||||
|
const wrapper = shallowMount(textboxQuestion, {
|
||||||
|
global: {
|
||||||
|
directives: {
|
||||||
|
maska: maska,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
propsData: {
|
||||||
|
modelValue: "foo",
|
||||||
|
validationRules: "spec-always-invalid",
|
||||||
|
},
|
||||||
|
mixins: [mockMixin],
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.vm.handleChange = jest.fn().mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await wrapper.vm.$options.watch.value.call(wrapper.vm, "bar");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(wrapper.vm.handleChange).toHaveBeenCalledWith("bar", false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Should not display camera or spinner icon when disabled", () => {
|
it("Should not display camera or spinner icon when disabled", () => {
|
||||||
|
|
|
||||||
|
|
@ -252,9 +252,15 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await validate(newValue, this.validationRules); // do a test validation check, without triggering full validation
|
const result = await validate(newValue, this.validationRules); // do a test validation check, without triggering full validation
|
||||||
if (result.valid) {
|
// Always sync vee-validate's internal field value with what the user actually
|
||||||
this.handleChange(newValue); // trigger full validation on this field only
|
// typed. Previously this only happened when the pre-check passed, so a field
|
||||||
}
|
// that never reached a "valid" intermediate state while typing (e.g. an
|
||||||
|
// 8-character alpha-only policy number) would never sync/validate at all,
|
||||||
|
// silently swallowing its error message on blur/submit. Passing result.valid
|
||||||
|
// as the shouldValidate flag keeps the no-flicker-while-typing behavior (errors
|
||||||
|
// aren't displayed until the value is actually valid or a full validation runs),
|
||||||
|
// while still keeping meta/value accurate for that later validation.
|
||||||
|
this.handleChange(newValue, result.valid);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import { shallowMount } from "@vue/test-utils";
|
||||||
import { getMountOptions } from "@/helpers/unit-test-helper.js";
|
import { getMountOptions } from "@/helpers/unit-test-helper.js";
|
||||||
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
|
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
|
||||||
import { settleAllPromises } from "@/helpers/layout-helper";
|
import { settleAllPromises } from "@/helpers/layout-helper";
|
||||||
|
import store from "@/store";
|
||||||
|
import { applicationConfig } from "@/constants/application-config";
|
||||||
|
import { coverageStatus } from "@/constants/insurance";
|
||||||
|
|
||||||
// Mock our module for promises.
|
// Mock our module for promises.
|
||||||
jest.mock("@/helpers/layout-helper.js", () => ({
|
jest.mock("@/helpers/layout-helper.js", () => ({
|
||||||
|
|
@ -37,13 +40,77 @@ describe("coverage-statement.vue", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("methods", () => {
|
describe("arePagePrerequisitesValid", () => {
|
||||||
test("arePagePrerequisitesValid returns true", () => {
|
test("returns true when all prerequisites are valid", () => {
|
||||||
const { wrapper } = setupMocks();
|
const { wrapper } = setupMocks();
|
||||||
|
|
||||||
expect(wrapper.vm.arePagePrerequisitesValid()).toBe(true);
|
expect(wrapper.vm.arePagePrerequisitesValid()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("returns true when claim and coverage is selected, regardless of other prerequisites", () => {
|
||||||
|
const { wrapper } = setupMocks();
|
||||||
|
store.getters.order.payment.isClaimAndCoverage = true;
|
||||||
|
store.getters.order.serviceLocation.zipCode = null;
|
||||||
|
|
||||||
|
expect(wrapper.vm.arePagePrerequisitesValid()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false when service zip info is missing", () => {
|
||||||
|
const { wrapper } = setupMocks();
|
||||||
|
store.getters.order.serviceLocation.zipCode = null;
|
||||||
|
|
||||||
|
expect(wrapper.vm.arePagePrerequisitesValid()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false when glass parts or repair info is missing", () => {
|
||||||
|
const { wrapper } = setupMocks();
|
||||||
|
store.getters.order.damage.isRepair = false;
|
||||||
|
store.getters.order.lineItems.glassParts = [];
|
||||||
|
|
||||||
|
expect(wrapper.vm.arePagePrerequisitesValid()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false when insurance info is invalid", () => {
|
||||||
|
const { wrapper } = setupMocks();
|
||||||
|
store.getters.order.payment.isInsurance = null;
|
||||||
|
|
||||||
|
expect(wrapper.vm.arePagePrerequisitesValid()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createValidOrderForCoverageStatement() {
|
||||||
|
return {
|
||||||
|
payment: {
|
||||||
|
isClaimAndCoverage: false,
|
||||||
|
isInsurance: false,
|
||||||
|
parentAccountNumber: applicationConfig.CASH_PARENT_ACCOUNT_NUMBER,
|
||||||
|
insuranceCoverage: {
|
||||||
|
isVerified: true,
|
||||||
|
coverageStatus: coverageStatus.VERIFIED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
policy: {
|
||||||
|
currentDeductible: 1000,
|
||||||
|
policyNumber: "POL123",
|
||||||
|
isItac: false,
|
||||||
|
isNoComp: false,
|
||||||
|
},
|
||||||
|
serviceLocation: {
|
||||||
|
zipCode: "00000",
|
||||||
|
zipCodeCtu: "00000",
|
||||||
|
},
|
||||||
|
damage: {
|
||||||
|
isRepair: false,
|
||||||
|
},
|
||||||
|
lineItems: {
|
||||||
|
glassParts: [{ id: "part1", partType: "WINDSHIELD" }],
|
||||||
|
promos: [],
|
||||||
|
supportingItems: [],
|
||||||
|
vaps: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function setupMocks() {
|
function setupMocks() {
|
||||||
const mockCmsContent = {
|
const mockCmsContent = {
|
||||||
|
|
@ -55,6 +122,10 @@ function setupMocks() {
|
||||||
fetchCmsContentForPage.mockResolvedValue(mockCmsContent);
|
fetchCmsContentForPage.mockResolvedValue(mockCmsContent);
|
||||||
settleAllPromises.mockResolvedValue({ cmsContent: mockCmsContent });
|
settleAllPromises.mockResolvedValue({ cmsContent: mockCmsContent });
|
||||||
|
|
||||||
|
store.getters = {
|
||||||
|
order: createValidOrderForCoverageStatement(),
|
||||||
|
};
|
||||||
|
|
||||||
const mountOptions = getMountOptions({
|
const mountOptions = getMountOptions({
|
||||||
route: { name: "duplicate-check", query: {}, params: {} },
|
route: { name: "duplicate-check", query: {}, params: {} },
|
||||||
router: {
|
router: {
|
||||||
|
|
@ -63,19 +134,7 @@ function setupMocks() {
|
||||||
navigateWithoutSaving: jest.fn(),
|
navigateWithoutSaving: jest.fn(),
|
||||||
},
|
},
|
||||||
store: {
|
store: {
|
||||||
getters: {
|
getters: store.getters,
|
||||||
order: {
|
|
||||||
policy: {
|
|
||||||
currentDeductible: 1000,
|
|
||||||
},
|
|
||||||
lineItems: {
|
|
||||||
glassParts: [],
|
|
||||||
promos: [],
|
|
||||||
supportingItems: [],
|
|
||||||
vaps: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
mixins: [
|
mixins: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,12 @@ import contentGroupModal from "@/fmg-components/content-group-modal/content-grou
|
||||||
import textBlock from "@/digital-components/text-block/text-block.vue";
|
import textBlock from "@/digital-components/text-block/text-block.vue";
|
||||||
import { debugLog } from "@/helpers/debug-log-helper";
|
import { debugLog } from "@/helpers/debug-log-helper";
|
||||||
import { loadSessionIfPresent } from "@/helpers/heritage-integration/order-helper";
|
import { loadSessionIfPresent } from "@/helpers/heritage-integration/order-helper";
|
||||||
|
import {
|
||||||
|
flushPagePrereqsLogs,
|
||||||
|
hasServiceZipInfo,
|
||||||
|
hasInsuranceInfo,
|
||||||
|
hasGlassPartsOrRepairInfo,
|
||||||
|
} from "@/helpers/page-prerequisites-helper.js";
|
||||||
export default {
|
export default {
|
||||||
name: "coverage-statement",
|
name: "coverage-statement",
|
||||||
mixins: [],
|
mixins: [],
|
||||||
|
|
@ -199,7 +205,20 @@ export default {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
arePagePrerequisitesValid() {
|
arePagePrerequisitesValid() {
|
||||||
return true;
|
const order = store.getters.order;
|
||||||
|
if (order.payment.isClaimAndCoverage) {
|
||||||
|
return true; //TODO: Future card when C&C flow is implemented
|
||||||
|
}
|
||||||
|
|
||||||
|
const logQueue = [];
|
||||||
|
|
||||||
|
const serviceZip = hasServiceZipInfo(order, logQueue);
|
||||||
|
const glassPartsOrRepair = hasGlassPartsOrRepairInfo(order, logQueue);
|
||||||
|
const insuranceInfo = hasInsuranceInfo(order, logQueue);
|
||||||
|
const preReqResult = serviceZip && glassPartsOrRepair && insuranceInfo;
|
||||||
|
|
||||||
|
flushPagePrereqsLogs("coverage-statement.vue", preReqResult, logQueue);
|
||||||
|
return preReqResult;
|
||||||
},
|
},
|
||||||
openModalAction(modalName) {
|
openModalAction(modalName) {
|
||||||
this.$refs[modalName]?.openModal();
|
this.$refs[modalName]?.openModal();
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,12 @@
|
||||||
|
|
||||||
<textboxQuestion
|
<textboxQuestion
|
||||||
v-if="displayClaimNumber"
|
v-if="displayClaimNumber"
|
||||||
isRequired
|
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
cmsWidgetName="ClaimNumberWidget"
|
cmsWidgetName="ClaimNumberWidget"
|
||||||
v-model="claimNumber"
|
v-model="claimNumber"
|
||||||
inputId="claimNumber" />
|
inputId="claimNumber"
|
||||||
|
maxLength="8"
|
||||||
|
validationRules="claim-number-numeric|claim-number-format" />
|
||||||
|
|
||||||
<dropdownQuestion
|
<dropdownQuestion
|
||||||
isRequired
|
isRequired
|
||||||
|
|
@ -151,6 +152,12 @@ import { regex } from "@/helpers/validation-rules";
|
||||||
import { coverageStatus, coverageStatusEnum } from "@/constants/insurance";
|
import { coverageStatus, coverageStatusEnum } from "@/constants/insurance";
|
||||||
import saveProgressModalQuestion from "@/fmg-components/save-progress-modal-question/save-progress-modal-question";
|
import saveProgressModalQuestion from "@/fmg-components/save-progress-modal-question/save-progress-modal-question";
|
||||||
import { debugLog } from "@/helpers/debug-log-helper";
|
import { debugLog } from "@/helpers/debug-log-helper";
|
||||||
|
import {
|
||||||
|
flushPagePrereqsLogs,
|
||||||
|
hasServiceZipInfo,
|
||||||
|
hasGlassPartsOrRepairInfo,
|
||||||
|
hasInsuranceInfo,
|
||||||
|
} from "@/helpers/page-prerequisites-helper.js";
|
||||||
|
|
||||||
// DEFINE VALIDATION RULES
|
// DEFINE VALIDATION RULES
|
||||||
defineRule(
|
defineRule(
|
||||||
|
|
@ -165,6 +172,8 @@ defineRule("city-required", required(errorMessages.CITY_REQUIRED));
|
||||||
defineRule("state-required", required(errorMessages.STATE_REQUIRED));
|
defineRule("state-required", required(errorMessages.STATE_REQUIRED));
|
||||||
defineRule("zip-code-required", required(errorMessages.ZIP_CODE_REQUIRED));
|
defineRule("zip-code-required", required(errorMessages.ZIP_CODE_REQUIRED));
|
||||||
defineRule("zip-code-format", regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.ZIP_CODE_FORMAT));
|
defineRule("zip-code-format", regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.ZIP_CODE_FORMAT));
|
||||||
|
defineRule("claim-number-numeric", regex(/^\d+$/, errorMessages.CLAIM_NUMBER_VALID));
|
||||||
|
defineRule("claim-number-format", regex(/^\d{6,8}$/, errorMessages.CLAIM_NUMBER_FORMAT));
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "insurance-details",
|
name: "insurance-details",
|
||||||
|
|
@ -363,7 +372,15 @@ export default {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
arePagePrerequisitesValid() {
|
arePagePrerequisitesValid() {
|
||||||
return true;
|
const order = store.getters.order;
|
||||||
|
const logQueue = [];
|
||||||
|
|
||||||
|
const serviceZip = hasServiceZipInfo(order, logQueue);
|
||||||
|
const glassPartsOrRepair = hasGlassPartsOrRepairInfo(order, logQueue);
|
||||||
|
const preReqResult = serviceZip && glassPartsOrRepair;
|
||||||
|
|
||||||
|
flushPagePrereqsLogs("insurance-details.vue", preReqResult, logQueue);
|
||||||
|
return preReqResult;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@
|
||||||
<script>
|
<script>
|
||||||
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
|
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
|
||||||
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
|
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
|
||||||
import { parentAccountNumbers } from "@/constants/insurance";
|
|
||||||
import buttonMain from "@/ux-components/button-main/button-main";
|
import buttonMain from "@/ux-components/button-main/button-main";
|
||||||
import { Form } from "vee-validate";
|
import { Form } from "vee-validate";
|
||||||
|
|
||||||
|
|
@ -98,9 +97,6 @@ export default {
|
||||||
displayDisclaimerText() {
|
displayDisclaimerText() {
|
||||||
return this.disclaimerText !== null && this.disclaimerText !== "";
|
return this.disclaimerText !== null && this.disclaimerText !== "";
|
||||||
},
|
},
|
||||||
parentAccountNumbers() {
|
|
||||||
return parentAccountNumbers;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import policyInfo from "@/layouts/policy-info/policy-info.vue";
|
import policyInfo from "@/layouts/policy-info/policy-info.vue";
|
||||||
import { shallowMount } from "@vue/test-utils";
|
import { shallowMount, mount, flushPromises } from "@vue/test-utils";
|
||||||
import { getMountOptions } from "@/helpers/unit-test-helper.js";
|
import { getMountOptions } from "@/helpers/unit-test-helper.js";
|
||||||
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
|
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
|
||||||
import { settleAllPromises } from "@/helpers/layout-helper";
|
import { settleAllPromises } from "@/helpers/layout-helper";
|
||||||
|
import { validate, Form } from "vee-validate";
|
||||||
|
import { errorMessages } from "@/constants/error-messages";
|
||||||
|
import textboxQuestion from "@/digital-components/textbox-question/textbox-question";
|
||||||
|
|
||||||
// Mock our module for promises.
|
// Mock our module for promises.
|
||||||
jest.mock("@/helpers/layout-helper.js", () => ({
|
jest.mock("@/helpers/layout-helper.js", () => ({
|
||||||
|
|
@ -43,6 +46,391 @@ describe("policy-info.vue", () => {
|
||||||
expect(wrapper.vm.arePagePrerequisitesValid()).toBe(true);
|
expect(wrapper.vm.arePagePrerequisitesValid()).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("forwardButtonAction", () => {
|
||||||
|
// Regression coverage: clicking Continue must explicitly (re)validate the form
|
||||||
|
// rather than assuming meta.valid/isForwardActionDisabled already caught everything,
|
||||||
|
// since that was the actual gap behind "I typed an invalid policy number, clicked
|
||||||
|
// Continue, and the message never appeared".
|
||||||
|
test("does not save or navigate when the form is invalid", async () => {
|
||||||
|
const { wrapper } = setupMocks();
|
||||||
|
wrapper.vm.isFormValid = jest.fn().mockResolvedValue(false);
|
||||||
|
|
||||||
|
await wrapper.vm.forwardButtonAction();
|
||||||
|
|
||||||
|
expect(wrapper.vm.isFormValid).toHaveBeenCalledWith(wrapper.vm.$refs.theForm);
|
||||||
|
expect(wrapper.vm.dispatchStoreAction).not.toHaveBeenCalled();
|
||||||
|
expect(wrapper.vm.$router.navigateWithSaving).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saves and navigates when the form is valid", async () => {
|
||||||
|
const { wrapper } = setupMocks();
|
||||||
|
wrapper.vm.isFormValid = jest.fn().mockResolvedValue(true);
|
||||||
|
|
||||||
|
await wrapper.vm.forwardButtonAction();
|
||||||
|
|
||||||
|
expect(wrapper.vm.dispatchStoreAction).toHaveBeenCalled();
|
||||||
|
expect(wrapper.vm.$router.navigateWithSaving).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("policy-number-format validation rule", () => {
|
||||||
|
test("passes when no PolicyNumberValidationWidget config is returned", async () => {
|
||||||
|
const { wrapper } = await setupMocksWithPolicyNumberValidationWidget(undefined);
|
||||||
|
|
||||||
|
const result = await validate("AB1", "policy-number-format");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(wrapper.vm).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes when field is empty (required rule handles that case)", async () => {
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 8,
|
||||||
|
maximumLength: 9,
|
||||||
|
validationRuleType: "Alpha",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validate("", "policy-number-format");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails when value is shorter than minimumLength", async () => {
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 8,
|
||||||
|
maximumLength: 9,
|
||||||
|
validationRuleType: "Alpha",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validate("ABCDEF", "policy-number-format");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails when value is longer than maximumLength", async () => {
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 8,
|
||||||
|
maximumLength: 9,
|
||||||
|
validationRuleType: "Alpha",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validate("ABCDEFGHIJ", "policy-number-format");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes for a valid Alpha value within length bounds", async () => {
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 8,
|
||||||
|
maximumLength: 9,
|
||||||
|
validationRuleType: "Alpha",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validate("ABCDEFGH", "policy-number-format");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails for an Alpha rule when value contains digits", async () => {
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 8,
|
||||||
|
maximumLength: 9,
|
||||||
|
validationRuleType: "Alpha",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validate("ABCDEFG1", "policy-number-format");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes for a valid numeric value", async () => {
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 6,
|
||||||
|
maximumLength: 10,
|
||||||
|
validationRuleType: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validate("123456", "policy-number-format");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails for a numeric rule when value contains letters", async () => {
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 6,
|
||||||
|
maximumLength: 10,
|
||||||
|
validationRuleType: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validate("12345A", "policy-number-format");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes for a valid AlphaNumeric value", async () => {
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 9,
|
||||||
|
maximumLength: 12,
|
||||||
|
validationRuleType: "AlphaNumeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validate("123456789a", "policy-number-format");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails for an AlphaNumeric rule when value contains a space", async () => {
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 9,
|
||||||
|
maximumLength: 12,
|
||||||
|
validationRuleType: "AlphaNumeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validate("12345678 a", "policy-number-format");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails for an AlphaNumeric rule when value contains a dash", async () => {
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 9,
|
||||||
|
maximumLength: 12,
|
||||||
|
validationRuleType: "AlphaNumeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validate("12345678-a", "policy-number-format");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes for a valid AlphaNumericWithSpaces value", async () => {
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 6,
|
||||||
|
maximumLength: 12,
|
||||||
|
validationRuleType: "AlphaNumericWithSpaces",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validate("ABC 123", "policy-number-format");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails for an AlphaNumericWithSpaces rule when value contains other symbols", async () => {
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 6,
|
||||||
|
maximumLength: 12,
|
||||||
|
validationRuleType: "AlphaNumericWithSpaces",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validate("ABC-123", "policy-number-format");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the CMS-provided minValidationMessage when value is shorter than minimumLength", async () => {
|
||||||
|
const minValidationMessage = "Policy number should be at least 8 characters.";
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 8,
|
||||||
|
maximumLength: 9,
|
||||||
|
validationRuleType: "Alpha",
|
||||||
|
minValidationMessage,
|
||||||
|
maxValidationMessage: "Policy number must be 9 characters or less.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validate("ABCDEF", "policy-number-format");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toEqual([minValidationMessage]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the CMS-provided maxValidationMessage when value is longer than maximumLength", async () => {
|
||||||
|
const maxValidationMessage = "Policy number must be 9 characters or less.";
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 8,
|
||||||
|
maximumLength: 9,
|
||||||
|
validationRuleType: "Alpha",
|
||||||
|
minValidationMessage: "Policy number should be at least 8 characters.",
|
||||||
|
maxValidationMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validate("ABCDEFGHIJ", "policy-number-format");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toEqual([maxValidationMessage]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to the default error message when minValidationMessage is not provided", async () => {
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 8,
|
||||||
|
maximumLength: 9,
|
||||||
|
validationRuleType: "Alpha",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validate("ABCDEF", "policy-number-format");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toEqual([errorMessages.POLICY_NUMBER_FORMAT]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to the default error message when maxValidationMessage is not provided", async () => {
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 8,
|
||||||
|
maximumLength: 9,
|
||||||
|
validationRuleType: "Alpha",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validate("ABCDEFGHIJ", "policy-number-format");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toEqual([errorMessages.POLICY_NUMBER_FORMAT]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the CMS-provided invalidCharMessage for a pattern-only failure (value within length bounds)", async () => {
|
||||||
|
const invalidCharMessage = "Policy number may only contain letters.";
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 8,
|
||||||
|
maximumLength: 9,
|
||||||
|
validationRuleType: "Alpha",
|
||||||
|
minValidationMessage: "Policy number should be at least 8 characters.",
|
||||||
|
maxValidationMessage: "Policy number must be 9 characters or less.",
|
||||||
|
invalidCharMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validate("ABCDEFG1", "policy-number-format");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toEqual([invalidCharMessage]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to the default error message for a pattern-only failure when invalidCharMessage is not provided", async () => {
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 8,
|
||||||
|
maximumLength: 9,
|
||||||
|
validationRuleType: "Alpha",
|
||||||
|
minValidationMessage: "Policy number should be at least 8 characters.",
|
||||||
|
maxValidationMessage: "Policy number must be 9 characters or less.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validate("ABCDEFG1", "policy-number-format");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toEqual([errorMessages.POLICY_NUMBER_FORMAT]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("policyNumber field end-to-end (via textbox-question)", () => {
|
||||||
|
// Regression test for a reported bug: typing a value that never passes through a
|
||||||
|
// "valid" intermediate state (e.g. "a@2" against an 8-9 char Alpha-only rule) left
|
||||||
|
// vee-validate's field `meta.valid` stuck at `true`, since textbox-question's value
|
||||||
|
// watcher only synced/validated the field when a pre-check said the value was valid.
|
||||||
|
// In the app this drives the Forward button's disabled state
|
||||||
|
// (:isForwardActionDisabled="!meta.valid"), so the bug meant a user could type an
|
||||||
|
// invalid policy number and the form would not flag it as invalid at all - it only
|
||||||
|
// "self-corrected" via the input's native `change` event on blur, which doesn't fire
|
||||||
|
// from keystrokes alone. This test dispatches only "input" events (like real typing)
|
||||||
|
// and intentionally does NOT fire a native "change"/blur event, to isolate the
|
||||||
|
// watcher's own behavior from that unrelated safety net.
|
||||||
|
test("marks the field invalid as soon as an unrecoverable value is typed, without waiting for blur", async () => {
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 8,
|
||||||
|
maximumLength: 9,
|
||||||
|
validationRuleType: "Alpha",
|
||||||
|
minValidationMessage: "Policy number should be at least 8 characters.",
|
||||||
|
maxValidationMessage: "Policy number must be 9 characters or less.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mount(textboxQuestion, {
|
||||||
|
global: {
|
||||||
|
directives: { maska: jest.fn() },
|
||||||
|
mixins: [
|
||||||
|
{
|
||||||
|
methods: {
|
||||||
|
getCmsContent: jest.fn(() => ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
// Start from a value that already satisfies the rule, so meta.valid
|
||||||
|
// begins as true (isolating the watcher's behavior from the
|
||||||
|
// required-on-empty check, which is independently already true on
|
||||||
|
// mount regardless of this bug).
|
||||||
|
modelValue: "ABCDEFGH",
|
||||||
|
inputId: "policyNumber",
|
||||||
|
validationRules: "policy-number-required|policy-number-format",
|
||||||
|
"onUpdate:modelValue": (newValue) => wrapper.setProps({ modelValue: newValue }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
expect(wrapper.vm.meta.valid).toBe(true);
|
||||||
|
|
||||||
|
const input = wrapper.find("input");
|
||||||
|
input.element.value = "a@2";
|
||||||
|
await input.trigger("input"); // simulate typing only, no blur/change
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.vm.meta.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reproduces "I typed a@2 and clicked Continue, and the message never appeared".
|
||||||
|
// Clicking Continue submits the real <Form>, which vee-validate intercepts and
|
||||||
|
// fully (re)validates every field using that field's own internally tracked value -
|
||||||
|
// not necessarily whatever is visible in the DOM input. If that internal value is
|
||||||
|
// stale (the underlying bug), the message can still fail to appear even though
|
||||||
|
// meta.valid correctly flips to false while typing.
|
||||||
|
test("shows the CMS validation message in the DOM after typing an invalid value and submitting the form", async () => {
|
||||||
|
const minValidationMessage = "Policy number should be at least 8 characters.";
|
||||||
|
await setupMocksWithPolicyNumberValidationWidget({
|
||||||
|
minimumLength: 8,
|
||||||
|
maximumLength: 9,
|
||||||
|
validationRuleType: "Alpha",
|
||||||
|
minValidationMessage,
|
||||||
|
maxValidationMessage: "Policy number must be 9 characters or less.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const harness = {
|
||||||
|
components: { Form, textboxQuestion },
|
||||||
|
data() {
|
||||||
|
return { policyNumber: "ABCDEFGH" };
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<Form v-slot="{ meta }" @submit="() => {}">
|
||||||
|
<textboxQuestion
|
||||||
|
v-model="policyNumber"
|
||||||
|
inputId="policyNumber"
|
||||||
|
validationRules="policy-number-required|policy-number-format" />
|
||||||
|
<button type="submit" :disabled="!meta.valid">Continue</button>
|
||||||
|
</Form>
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = mount(harness, {
|
||||||
|
global: {
|
||||||
|
directives: { maska: jest.fn() },
|
||||||
|
mixins: [
|
||||||
|
{
|
||||||
|
methods: {
|
||||||
|
getCmsContent: jest.fn(() => ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const input = wrapper.find("input");
|
||||||
|
input.element.value = "a@2";
|
||||||
|
await input.trigger("input"); // simulate typing only, no blur/change
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
// Mirrors clicking the real "Continue" button, which submits the <form>.
|
||||||
|
await wrapper.find("form").trigger("submit");
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain(minValidationMessage);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupMocks() {
|
function setupMocks() {
|
||||||
|
|
@ -78,3 +466,30 @@ function setupMocks() {
|
||||||
|
|
||||||
return { wrapper };
|
return { wrapper };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Runs beforeRouteEnter with a clientConfig that includes the given
|
||||||
|
// PolicyNumberValidationWidget config so the module-scoped rule config gets populated,
|
||||||
|
// mirroring how the real CMS response would flow through the route guard. Like
|
||||||
|
// ConfigWidget, the real CMS wraps the widget's config in a JSON-encoded `Text`
|
||||||
|
// string rather than returning a plain object, so we replicate that shape here too.
|
||||||
|
async function setupMocksWithPolicyNumberValidationWidget(policyNumberValidationConfig) {
|
||||||
|
const { wrapper } = setupMocks();
|
||||||
|
|
||||||
|
settleAllPromises.mockResolvedValue({
|
||||||
|
cmsContent: {},
|
||||||
|
clientConfig: {
|
||||||
|
PolicyNumberValidationWidget: policyNumberValidationConfig
|
||||||
|
? { Text: JSON.stringify(policyNumberValidationConfig) }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await policyInfo.beforeRouteEnter.call(
|
||||||
|
wrapper.vm,
|
||||||
|
{ name: "policy-info" },
|
||||||
|
undefined,
|
||||||
|
(callback) => callback(wrapper.vm)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { wrapper };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
:overrideQuestionText="policyNumberLabelOverride"
|
:overrideQuestionText="policyNumberLabelOverride"
|
||||||
v-model="policyNumber"
|
v-model="policyNumber"
|
||||||
inputId="policyNumber"
|
inputId="policyNumber"
|
||||||
validationRules="policy-number-required" />
|
validationRules="policy-number-required|policy-number-format" />
|
||||||
<span
|
<span
|
||||||
v-if="displayPolicyHelperText"
|
v-if="displayPolicyHelperText"
|
||||||
class="d-block mb-5 helper-text"
|
class="d-block mb-5 helper-text"
|
||||||
|
|
@ -222,7 +222,88 @@ import { settleAllPromises } from "@/helpers/layout-helper";
|
||||||
import store from "@/store";
|
import store from "@/store";
|
||||||
import { debugLog } from "@/helpers/debug-log-helper";
|
import { debugLog } from "@/helpers/debug-log-helper";
|
||||||
|
|
||||||
|
// The PolicyNumberValidationWidget is fetched asynchronously from the CMS (see
|
||||||
|
// beforeRouteEnter below), so the format rule reads it from this module-scoped
|
||||||
|
// reference rather than capturing a value at defineRule time.
|
||||||
|
let policyNumberValidationConfig = null;
|
||||||
|
|
||||||
|
function getPolicyNumberFormatPattern(validationRuleType) {
|
||||||
|
switch ((validationRuleType || "").toLowerCase()) {
|
||||||
|
case "alpha":
|
||||||
|
return /^[A-Za-z]+$/;
|
||||||
|
case "numeric":
|
||||||
|
return /^[0-9]+$/;
|
||||||
|
case "alphanumeric":
|
||||||
|
return /^[A-Za-z0-9]+$/;
|
||||||
|
case "alphanumericwithspaces":
|
||||||
|
return /^[A-Za-z0-9 ]+$/;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Like ConfigWidget, PolicyNumberValidationWidget's actual config comes back from the CMS
|
||||||
|
// as a JSON-encoded string in its `Text` property, not as a plain object.
|
||||||
|
// examples:
|
||||||
|
/* {
|
||||||
|
"minimumLength": 1,
|
||||||
|
"maximumLength": 16,
|
||||||
|
"validationRuleType": "AlphaNumeric",
|
||||||
|
"minValidationMessage": "",
|
||||||
|
"maxValidationMessage": "That looks like a VIN. Your policy number should be 16 characters maximum.",
|
||||||
|
"invalidCharMessage": "Policy number must be numbers and letters only."
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"minimumLength": 8,
|
||||||
|
"maximumLength": 9,
|
||||||
|
"validationRuleType": "AlphaNumeric",
|
||||||
|
"minValidationMessage": "Policy number should be at least 8 characters.",
|
||||||
|
"maxValidationMessage": "Policy number must be 9 characters or less."
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
function parsePolicyNumberValidationWidget(widget) {
|
||||||
|
const raw = widget?.Text;
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to parse PolicyNumberValidationWidget.Text", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defineRule("policy-number-required", required(errorMessages.POLICY_NUMBER_REQUIRED));
|
defineRule("policy-number-required", required(errorMessages.POLICY_NUMBER_REQUIRED));
|
||||||
|
defineRule("policy-number-format", (value) => {
|
||||||
|
if (!value || !value.length || !policyNumberValidationConfig) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
minimumLength,
|
||||||
|
maximumLength,
|
||||||
|
validationRuleType,
|
||||||
|
minValidationMessage,
|
||||||
|
maxValidationMessage,
|
||||||
|
invalidCharMessage,
|
||||||
|
} = policyNumberValidationConfig;
|
||||||
|
|
||||||
|
if (typeof minimumLength === "number" && value.length < minimumLength) {
|
||||||
|
return minValidationMessage || errorMessages.POLICY_NUMBER_FORMAT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof maximumLength === "number" && value.length > maximumLength) {
|
||||||
|
return maxValidationMessage || errorMessages.POLICY_NUMBER_FORMAT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = getPolicyNumberFormatPattern(validationRuleType);
|
||||||
|
if (pattern && !pattern.test(value)) {
|
||||||
|
return invalidCharMessage || errorMessages.POLICY_NUMBER_FORMAT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
defineRule("zip-code-required", required(errorMessages.ZIP_REQUIRED));
|
defineRule("zip-code-required", required(errorMessages.ZIP_REQUIRED));
|
||||||
defineRule("zip-code-format", regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.ZIP_FORMAT));
|
defineRule("zip-code-format", regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.ZIP_FORMAT));
|
||||||
defineRule("phone-number-required", required(errorMessages.PHONE_REQUIRED));
|
defineRule("phone-number-required", required(errorMessages.PHONE_REQUIRED));
|
||||||
|
|
@ -412,6 +493,9 @@ export default {
|
||||||
next((vm) => {
|
next((vm) => {
|
||||||
vm.setCmsContent(resultMap.cmsContent);
|
vm.setCmsContent(resultMap.cmsContent);
|
||||||
vm.clientConfig = resultMap.clientConfig ?? {};
|
vm.clientConfig = resultMap.clientConfig ?? {};
|
||||||
|
policyNumberValidationConfig = parsePolicyNumberValidationWidget(
|
||||||
|
vm.clientConfig?.PolicyNumberValidationWidget
|
||||||
|
);
|
||||||
// The load-session API can return damageCause in a different case than
|
// The load-session API can return damageCause in a different case than
|
||||||
// the CMS answer values. Reconcile it so the dropdown pre-selects.
|
// the CMS answer values. Reconcile it so the dropdown pre-selects.
|
||||||
vm.damageCause = vm.matchDamageCauseToOption(vm.damageCause);
|
vm.damageCause = vm.matchDamageCauseToOption(vm.damageCause);
|
||||||
|
|
@ -488,6 +572,17 @@ export default {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
async forwardButtonAction() {
|
async forwardButtonAction() {
|
||||||
|
// Explicitly (re)validate every field before navigating, rather than relying
|
||||||
|
// solely on isForwardActionDisabled/meta.valid already being accurate and on the
|
||||||
|
// Continue button's incidental native form-submission side effect to surface
|
||||||
|
// error messages. This guarantees the CMS-driven policy number format error (and
|
||||||
|
// any other field's error) is actually rendered when Continue is clicked while
|
||||||
|
// the form is invalid.
|
||||||
|
const isValid = await this.isFormValid(this.$refs.theForm);
|
||||||
|
if (!isValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.dispatchStoreAction(
|
this.dispatchStoreAction(
|
||||||
this.storeActions.SAVE_INSURANCE_DETAILS,
|
this.storeActions.SAVE_INSURANCE_DETAILS,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ import insuranceNavBar from "@/fmg-components/insurance-nav-bar/insurance-nav-ba
|
||||||
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
|
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
|
||||||
import store from "@/store";
|
import store from "@/store";
|
||||||
import buttonMain from "@/ux-components/button-main/button-main";
|
import buttonMain from "@/ux-components/button-main/button-main";
|
||||||
import { parentAccountNumbers } from "@/constants/insurance";
|
|
||||||
import { debugLog } from "@/helpers/debug-log-helper";
|
import { debugLog } from "@/helpers/debug-log-helper";
|
||||||
|
|
||||||
import { Form } from "vee-validate";
|
import { Form } from "vee-validate";
|
||||||
|
|
@ -106,9 +105,6 @@ export default {
|
||||||
displayDisclaimerText() {
|
displayDisclaimerText() {
|
||||||
return this.disclaimerText !== null && this.disclaimerText !== "";
|
return this.disclaimerText !== null && this.disclaimerText !== "";
|
||||||
},
|
},
|
||||||
parentAccountNumbers() {
|
|
||||||
return parentAccountNumbers;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { shallowMount } from "@vue/test-utils";
|
||||||
import scheduling from "./scheduling";
|
import scheduling from "./scheduling";
|
||||||
import { getMountOptions } from "@/helpers/unit-test-helper.js";
|
import { getMountOptions } from "@/helpers/unit-test-helper.js";
|
||||||
import { settleAllPromises } from "@/helpers/layout-helper";
|
import { settleAllPromises } from "@/helpers/layout-helper";
|
||||||
|
import store from "@/store";
|
||||||
|
|
||||||
jest.mock("@/store", () => ({
|
jest.mock("@/store", () => ({
|
||||||
dispatch: jest.fn().mockResolvedValue(null),
|
dispatch: jest.fn().mockResolvedValue(null),
|
||||||
|
|
@ -107,5 +108,72 @@ describe("scheduling.vue", () => {
|
||||||
await expect(wrapper.vm.handleRequestMoreDates()).resolves.toBeUndefined();
|
await expect(wrapper.vm.handleRequestMoreDates()).resolves.toBeUndefined();
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("batches inshop provider requests into a single getShopTimeSlotsV2 call", async () => {
|
||||||
|
store.dispatch.mockClear();
|
||||||
|
settleAllPromises.mockResolvedValueOnce({
|
||||||
|
inshopTimeSlots: {
|
||||||
|
providerTimeSlots: [
|
||||||
|
{ providerNumber: "05018", days: [{ date: "2026-07-22", timeSlots: [] }] },
|
||||||
|
{ providerNumber: "05019", days: [{ date: "2026-07-22", timeSlots: [] }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { wrapper } = setupMocks();
|
||||||
|
wrapper.vm.inShopProvidersAndTimeslots = [
|
||||||
|
{ provider: { providerNumber: "05018" }, timeSlots: { days: [] } },
|
||||||
|
{ provider: { providerNumber: "05019" }, timeSlots: { days: [] } },
|
||||||
|
];
|
||||||
|
wrapper.vm.datePickerEndDate = "2026-07-21";
|
||||||
|
|
||||||
|
await wrapper.vm.handleRequestMoreDates();
|
||||||
|
|
||||||
|
const shopTimeSlotDispatches = store.dispatch.mock.calls.filter(
|
||||||
|
([actionName]) => actionName === "getShopTimeSlotsV2"
|
||||||
|
);
|
||||||
|
expect(shopTimeSlotDispatches).toHaveLength(1);
|
||||||
|
expect(shopTimeSlotDispatches[0][1].payload.providerNumbers).toEqual([
|
||||||
|
"05018",
|
||||||
|
"05019",
|
||||||
|
]);
|
||||||
|
expect(shopTimeSlotDispatches[0][1].payload.endDate).toBeDefined();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps batched inshop providerTimeSlots back onto provider entries", async () => {
|
||||||
|
settleAllPromises.mockResolvedValueOnce({
|
||||||
|
inshopTimeSlots: {
|
||||||
|
providerTimeSlots: [
|
||||||
|
{
|
||||||
|
providerNumber: "05018",
|
||||||
|
days: [{ date: "2026-07-22", timeSlots: [{ id: "slot-a" }] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerNumber: "05019",
|
||||||
|
days: [{ date: "2026-07-22", timeSlots: [{ id: "slot-b" }] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { wrapper } = setupMocks();
|
||||||
|
wrapper.vm.inShopProvidersAndTimeslots = [
|
||||||
|
{ provider: { providerNumber: "05018" }, timeSlots: { days: [] } },
|
||||||
|
{ provider: { providerNumber: "05019" }, timeSlots: { days: [] } },
|
||||||
|
];
|
||||||
|
wrapper.vm.datePickerEndDate = "2026-07-21";
|
||||||
|
|
||||||
|
await wrapper.vm.handleRequestMoreDates();
|
||||||
|
|
||||||
|
expect(wrapper.vm.inShopProvidersAndTimeslots[0].timeSlots.days).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
wrapper.vm.inShopProvidersAndTimeslots[0].timeSlots.days[0].timeSlots[0].id
|
||||||
|
).toBe("slot-a");
|
||||||
|
expect(
|
||||||
|
wrapper.vm.inShopProvidersAndTimeslots[1].timeSlots.days[0].timeSlots[0].id
|
||||||
|
).toBe("slot-b");
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -111,16 +111,45 @@ function appendDays(entry, newSlots) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a promise for inshop time slots for a single provider.
|
* Assigns time slots from a v2 multi-provider response onto in-shop provider entries.
|
||||||
* @param {{ startDate: string, endDate: string, providerNumber: string, pageNameToLog: string }} params
|
* @param {Array<{ provider: { providerNumber: string }, timeSlots: any }>} entries
|
||||||
|
* @param {{ providerTimeSlots?: Array<{ providerNumber: string, days: any[], provisionalTriggers?: string[] }> } | null | undefined} multiProviderResponse
|
||||||
*/
|
*/
|
||||||
function fetchInshopTimeSlots({ startDate, endDate, providerNumber, pageNameToLog }) {
|
function assignInshopTimeSlotsFromV2Response(entries, multiProviderResponse) {
|
||||||
return store.dispatch("getShopTimeSlots", {
|
const providerTimeSlots = multiProviderResponse?.providerTimeSlots ?? [];
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
entry.timeSlots =
|
||||||
|
providerTimeSlots.find((pts) => pts.providerNumber === entry.provider.providerNumber) ??
|
||||||
|
null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends days from a v2 multi-provider response onto existing in-shop provider entries.
|
||||||
|
* @param {Array<{ provider: { providerNumber: string }, timeSlots: any }>} entries
|
||||||
|
* @param {{ providerTimeSlots?: Array<{ providerNumber: string, days: any[] }> } | null | undefined} multiProviderResponse
|
||||||
|
*/
|
||||||
|
function appendInshopTimeSlotsFromV2Response(entries, multiProviderResponse) {
|
||||||
|
const providerTimeSlots = multiProviderResponse?.providerTimeSlots ?? [];
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const newSlots = providerTimeSlots.find(
|
||||||
|
(pts) => pts.providerNumber === entry.provider.providerNumber
|
||||||
|
);
|
||||||
|
appendDays(entry, newSlots);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a promise for inshop time slots for multiple providers.
|
||||||
|
* @param {{ startDate: string, endDate: string, providerNumbers: string[], pageNameToLog: string }} params
|
||||||
|
*/
|
||||||
|
function fetchInshopTimeSlots({ startDate, endDate, providerNumbers, pageNameToLog }) {
|
||||||
|
return store.dispatch("getShopTimeSlotsV2", {
|
||||||
payload: {
|
payload: {
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
shopAppointmentType: "InshopOrDropoff",
|
shopAppointmentType: "InshopOrDropoff",
|
||||||
providerNumber,
|
providerNumbers,
|
||||||
},
|
},
|
||||||
pageNameToLog,
|
pageNameToLog,
|
||||||
});
|
});
|
||||||
|
|
@ -131,8 +160,12 @@ function fetchInshopTimeSlots({ startDate, endDate, providerNumber, pageNameToLo
|
||||||
* @param {{ startDate: string, endDate: string, zipCode: string, pageNameToLog: string }} params
|
* @param {{ startDate: string, endDate: string, zipCode: string, pageNameToLog: string }} params
|
||||||
*/
|
*/
|
||||||
function fetchMobileTimeSlots({ startDate, endDate, zipCode, pageNameToLog }) {
|
function fetchMobileTimeSlots({ startDate, endDate, zipCode, pageNameToLog }) {
|
||||||
return store.dispatch("getMobileTimeSlots", {
|
return store.dispatch("getMobileTimeSlotsV2", {
|
||||||
payload: { startDate, endDate, zipCode },
|
payload: {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
zipCode,
|
||||||
|
},
|
||||||
pageNameToLog,
|
pageNameToLog,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -176,16 +209,21 @@ export default {
|
||||||
: null;
|
: null;
|
||||||
const startDate = toDateString(0);
|
const startDate = toDateString(0);
|
||||||
const endDate = toDateString(SCHEDULE_FETCH_DAYS - 1);
|
const endDate = toDateString(SCHEDULE_FETCH_DAYS - 1);
|
||||||
|
const providerNumbers = providers.map((provider) => provider.providerNumber);
|
||||||
const timeSlotsPromiseResultMap = [
|
const timeSlotsPromiseResultMap = [
|
||||||
...providers.map((provider, i) => ({
|
...(providerNumbers.length
|
||||||
resultKey: `inshopTimeSlots_${i}`,
|
? [
|
||||||
|
{
|
||||||
|
resultKey: "inshopTimeSlots",
|
||||||
promise: fetchInshopTimeSlots({
|
promise: fetchInshopTimeSlots({
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
providerNumber: provider.providerNumber,
|
providerNumbers,
|
||||||
pageNameToLog: to.name,
|
pageNameToLog: to.name,
|
||||||
}),
|
}),
|
||||||
})),
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
// Get the mobile time slots if a mobile provider number is available
|
// Get the mobile time slots if a mobile provider number is available
|
||||||
...(mobileProviderNumber
|
...(mobileProviderNumber
|
||||||
? [
|
? [
|
||||||
|
|
@ -202,9 +240,10 @@ export default {
|
||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
const timeSlotsResultMap = await settleAllPromises(timeSlotsPromiseResultMap);
|
const timeSlotsResultMap = await settleAllPromises(timeSlotsPromiseResultMap);
|
||||||
vm.inShopProvidersAndTimeslots.forEach((entry, i) => {
|
assignInshopTimeSlotsFromV2Response(
|
||||||
entry.timeSlots = timeSlotsResultMap[`inshopTimeSlots_${i}`] ?? null;
|
vm.inShopProvidersAndTimeslots,
|
||||||
});
|
timeSlotsResultMap.inshopTimeSlots
|
||||||
|
);
|
||||||
if (vm.mobileProviderAndTimeSlot) {
|
if (vm.mobileProviderAndTimeSlot) {
|
||||||
vm.mobileProviderAndTimeSlot.timeSlots = timeSlotsResultMap.mobileTimeSlots ?? null;
|
vm.mobileProviderAndTimeSlot.timeSlots = timeSlotsResultMap.mobileTimeSlots ?? null;
|
||||||
}
|
}
|
||||||
|
|
@ -332,17 +371,24 @@ export default {
|
||||||
|
|
||||||
const newStartDate = toDateString(1, this.datePickerEndDate);
|
const newStartDate = toDateString(1, this.datePickerEndDate);
|
||||||
const newEndDate = toDateString(SCHEDULE_FETCH_DAYS, this.datePickerEndDate);
|
const newEndDate = toDateString(SCHEDULE_FETCH_DAYS, this.datePickerEndDate);
|
||||||
|
const providerNumbers = this.inShopProvidersAndTimeslots.map(
|
||||||
|
({ provider }) => provider.providerNumber
|
||||||
|
);
|
||||||
|
|
||||||
const promiseResultMap = [
|
const promiseResultMap = [
|
||||||
...this.inShopProvidersAndTimeslots.map(({ provider }, i) => ({
|
...(providerNumbers.length
|
||||||
resultKey: `inshopTimeSlots_${i}`,
|
? [
|
||||||
|
{
|
||||||
|
resultKey: "inshopTimeSlots",
|
||||||
promise: fetchInshopTimeSlots({
|
promise: fetchInshopTimeSlots({
|
||||||
startDate: newStartDate,
|
startDate: newStartDate,
|
||||||
endDate: newEndDate,
|
endDate: newEndDate,
|
||||||
providerNumber: provider.providerNumber,
|
providerNumbers,
|
||||||
pageNameToLog: this.pageName,
|
pageNameToLog: this.pageName,
|
||||||
}),
|
}),
|
||||||
})),
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(this.mobileProviderAndTimeSlot
|
...(this.mobileProviderAndTimeSlot
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|
@ -360,10 +406,10 @@ export default {
|
||||||
|
|
||||||
const resultMap = await settleAllPromises(promiseResultMap);
|
const resultMap = await settleAllPromises(promiseResultMap);
|
||||||
|
|
||||||
// Provider order is fixed after beforeRouteEnter, so index keys are stable.
|
appendInshopTimeSlotsFromV2Response(
|
||||||
this.inShopProvidersAndTimeslots.forEach((entry, index) => {
|
this.inShopProvidersAndTimeslots,
|
||||||
appendDays(entry, resultMap[`inshopTimeSlots_${index}`]);
|
resultMap.inshopTimeSlots
|
||||||
});
|
);
|
||||||
|
|
||||||
if (this.mobileProviderAndTimeSlot) {
|
if (this.mobileProviderAndTimeSlot) {
|
||||||
appendDays(this.mobileProviderAndTimeSlot, resultMap.mobileTimeSlots);
|
appendDays(this.mobileProviderAndTimeSlot, resultMap.mobileTimeSlots);
|
||||||
|
|
|
||||||
|
|
@ -2543,6 +2543,149 @@ export const actions = {
|
||||||
return globalMethods.callHttpClient(options);
|
return globalMethods.callHttpClient(options);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getShopTimeSlotsV2(
|
||||||
|
context,
|
||||||
|
{ payload: { startDate, endDate, shopAppointmentType, providerNumbers }, pageNameToLog }
|
||||||
|
) {
|
||||||
|
const order = context.state.order;
|
||||||
|
const vehicle = context.state.order.vehicle;
|
||||||
|
const payment = context.state.order.payment;
|
||||||
|
|
||||||
|
const lineItems = getFlattenedLineItemsWithGlassPartTag(order.lineItems);
|
||||||
|
|
||||||
|
const glassPieces = order.damage.glassToReplace
|
||||||
|
? convertGlassPieceToBackEndCompatibleFormat(order.damage.glassToReplace)
|
||||||
|
: [];
|
||||||
|
var payload = {
|
||||||
|
providerNumbers: providerNumbers,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
shopAppointmentType: shopAppointmentType,
|
||||||
|
applicationName: applicationConfig.APPLICATION_NAME,
|
||||||
|
parentAccountNumber: payment.parentAccountNumber,
|
||||||
|
carId: vehicle.carId,
|
||||||
|
lineItems: lineItems,
|
||||||
|
glassPieces: glassPieces,
|
||||||
|
eon: order.eon,
|
||||||
|
billToAccountNumber: context.getters.payment.billToAccountNumber,
|
||||||
|
coverage: {
|
||||||
|
status: payment.insuranceCoverage.coverageStatus,
|
||||||
|
deductible: order.policy.currentDeductible,
|
||||||
|
additionalAuthFlag: order.policy.additionalAuthFlag,
|
||||||
|
},
|
||||||
|
partSelection: {
|
||||||
|
hasAnsweredPartQuestions: !!order.damage.partQuestionAnswers?.length,
|
||||||
|
hasAnsweredMoldingQuestions: !!order.damage.moldingQuestionAnswers?.length,
|
||||||
|
hasAnsweredCapabilityQuestions: !!order.damage.capabilityQuestionAnswers?.length,
|
||||||
|
hasManuallySelectedParts:
|
||||||
|
!!context.state.applicationUser.pageData["vehicle-parts"]?.partsOrQuestions
|
||||||
|
.length,
|
||||||
|
},
|
||||||
|
vehicle: {
|
||||||
|
year: vehicle.year,
|
||||||
|
make: vehicle.make,
|
||||||
|
model: vehicle.model,
|
||||||
|
style: vehicle.style,
|
||||||
|
vin: vehicle.vin ?? "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let hasCalled = timeSlotCallFlags.shopV2;
|
||||||
|
if (!hasCalled) {
|
||||||
|
timeSlotCallFlags.shopV2 = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
method: endpoints.GetShopTimeSlotsV2.method,
|
||||||
|
endpoint: endpoints.GetShopTimeSlotsV2.url,
|
||||||
|
payload: payload,
|
||||||
|
logApiCall: true,
|
||||||
|
pageNameToLog: pageNameToLog,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only set handler if this is the very first call in this session
|
||||||
|
if (!hasCalled) {
|
||||||
|
options.additionalSuccessEventDataHandler = (response) => {
|
||||||
|
const firstProviderTimeSlots = response.data.providerTimeSlots?.[0];
|
||||||
|
return getTimeSlotsAdditionalEventData(
|
||||||
|
firstProviderTimeSlots?.provisionalTriggers,
|
||||||
|
order.serviceLocation.zipCode,
|
||||||
|
firstProviderTimeSlots?.days?.[0]?.date,
|
||||||
|
shopAppointmentType
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return globalMethods.callHttpClient(options);
|
||||||
|
},
|
||||||
|
|
||||||
|
getMobileTimeSlotsV2(context, { payload: { startDate, endDate, zipCode }, pageNameToLog }) {
|
||||||
|
const order = context.state.order;
|
||||||
|
const vehicle = context.state.order.vehicle;
|
||||||
|
const payment = context.state.order.payment;
|
||||||
|
const lineItems = getFlattenedLineItemsWithGlassPartTag(order.lineItems);
|
||||||
|
const glassPieces = order.damage.glassToReplace
|
||||||
|
? convertGlassPieceToBackEndCompatibleFormat(order.damage.glassToReplace)
|
||||||
|
: [];
|
||||||
|
var payload = {
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
applicationName: applicationConfig.APPLICATION_NAME,
|
||||||
|
parentAccountNumber: payment.parentAccountNumber,
|
||||||
|
carId: vehicle.carId,
|
||||||
|
lineItems: lineItems,
|
||||||
|
glassPieces: glassPieces,
|
||||||
|
eon: order.eon,
|
||||||
|
billToAccountNumber: context.getters.payment.billToAccountNumber,
|
||||||
|
coverage: {
|
||||||
|
status: payment.insuranceCoverage.coverageStatus,
|
||||||
|
deductible: order.policy.currentDeductible,
|
||||||
|
additionalAuthFlag: order.policy.additionalAuthFlag,
|
||||||
|
},
|
||||||
|
partSelection: {
|
||||||
|
hasAnsweredPartQuestions: !!order.damage.partQuestionAnswers?.length,
|
||||||
|
hasAnsweredMoldingQuestions: !!order.damage.moldingQuestionAnswers?.length,
|
||||||
|
hasAnsweredCapabilityQuestions: !!order.damage.capabilityQuestionAnswers?.length,
|
||||||
|
hasManuallySelectedParts:
|
||||||
|
!!context.state.applicationUser.pageData["vehicle-parts"]?.partsOrQuestions
|
||||||
|
.length,
|
||||||
|
},
|
||||||
|
vehicle: {
|
||||||
|
year: vehicle.year,
|
||||||
|
make: vehicle.make,
|
||||||
|
model: vehicle.model,
|
||||||
|
style: vehicle.style,
|
||||||
|
vin: vehicle.vin ?? "",
|
||||||
|
},
|
||||||
|
zipCode: zipCode ?? order.serviceLocation.zipCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
let hasCalled = timeSlotCallFlags.mobileV2;
|
||||||
|
if (!hasCalled) {
|
||||||
|
timeSlotCallFlags.mobileV2 = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
method: endpoints.GetMobileTimeSlotsV2.method,
|
||||||
|
endpoint: endpoints.GetMobileTimeSlotsV2.url,
|
||||||
|
payload: payload,
|
||||||
|
logApiCall: true,
|
||||||
|
pageNameToLog: pageNameToLog,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only set handler if this is the very first call in this session
|
||||||
|
if (!hasCalled) {
|
||||||
|
options.additionalSuccessEventDataHandler = (response) =>
|
||||||
|
getTimeSlotsAdditionalEventData(
|
||||||
|
response.data.provisionalTriggers,
|
||||||
|
order.serviceLocation.zipCode,
|
||||||
|
response.data.days?.[0]?.date
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return globalMethods.callHttpClient(options);
|
||||||
|
},
|
||||||
|
|
||||||
getMobilePremiumFee(context, { pageNameToLog }) {
|
getMobilePremiumFee(context, { pageNameToLog }) {
|
||||||
const damageType = context.getters.damage.isRepair ? "Repair" : "Replace";
|
const damageType = context.getters.damage.isRepair ? "Repair" : "Replace";
|
||||||
const paymentType = context.getters.order.payment.isInsurance ? "Insurance" : "Cash";
|
const paymentType = context.getters.order.payment.isInsurance ? "Insurance" : "Cash";
|
||||||
|
|
@ -4530,6 +4673,8 @@ function saveExternalParameterState(externalParameterState) {
|
||||||
const timeSlotCallFlags = {
|
const timeSlotCallFlags = {
|
||||||
shop: false,
|
shop: false,
|
||||||
mobile: false,
|
mobile: false,
|
||||||
|
shopV2: false,
|
||||||
|
mobileV2: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function checkIfMiscParts(partsOrQuestions) {
|
function checkIfMiscParts(partsOrQuestions) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import store from "@/store";
|
||||||
import { mutations, state, actions, getters } from "@/store";
|
import { mutations, state, actions, getters } from "@/store";
|
||||||
import { storeMutations } from "@/constants/store-mutations";
|
import { storeMutations } from "@/constants/store-mutations";
|
||||||
import { storeActions } from "@/constants/store-actions";
|
import { storeActions } from "@/constants/store-actions";
|
||||||
|
import { endpoints } from "@/constants/endpoints.js";
|
||||||
import { sessionStorageKeyConstants } from "@/constants/session-storage";
|
import { sessionStorageKeyConstants } from "@/constants/session-storage";
|
||||||
import { experimentTriggers } from "@/constants/experiments";
|
import { experimentTriggers } from "@/constants/experiments";
|
||||||
import { routeData } from "@/router/constants/routes";
|
import { routeData } from "@/router/constants/routes";
|
||||||
|
|
@ -3631,6 +3632,71 @@ describe("Actions", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("v2 time slot actions", () => {
|
||||||
|
const buildTimeSlotContext = () => ({
|
||||||
|
state,
|
||||||
|
getters: {
|
||||||
|
payment: { billToAccountNumber: "87291" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getShopTimeSlotsV2 calls v2 shop endpoint with providerNumbers and endDate", async () => {
|
||||||
|
const context = buildTimeSlotContext();
|
||||||
|
|
||||||
|
globalMethods.callHttpClient.mockResolvedValue({ data: { providerTimeSlots: [] } });
|
||||||
|
|
||||||
|
await actions.getShopTimeSlotsV2(context, {
|
||||||
|
payload: {
|
||||||
|
startDate: "2026-07-07",
|
||||||
|
endDate: "2026-07-21",
|
||||||
|
shopAppointmentType: "InshopOrDropoff",
|
||||||
|
providerNumbers: ["05018", "05019"],
|
||||||
|
},
|
||||||
|
pageNameToLog: "scheduling",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(globalMethods.callHttpClient).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: endpoints.GetShopTimeSlotsV2.method,
|
||||||
|
endpoint: endpoints.GetShopTimeSlotsV2.url,
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
startDate: "2026-07-07",
|
||||||
|
endDate: "2026-07-21",
|
||||||
|
providerNumbers: ["05018", "05019"],
|
||||||
|
shopAppointmentType: "InshopOrDropoff",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getMobileTimeSlotsV2 calls v2 mobile endpoint with endDate", async () => {
|
||||||
|
const context = buildTimeSlotContext();
|
||||||
|
|
||||||
|
globalMethods.callHttpClient.mockResolvedValue({ data: { days: [] } });
|
||||||
|
|
||||||
|
await actions.getMobileTimeSlotsV2(context, {
|
||||||
|
payload: {
|
||||||
|
startDate: "2026-07-07",
|
||||||
|
endDate: "2026-07-21",
|
||||||
|
zipCode: "43235",
|
||||||
|
},
|
||||||
|
pageNameToLog: "scheduling",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(globalMethods.callHttpClient).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: endpoints.GetMobileTimeSlotsV2.method,
|
||||||
|
endpoint: endpoints.GetMobileTimeSlotsV2.url,
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
startDate: "2026-07-07",
|
||||||
|
endDate: "2026-07-21",
|
||||||
|
zipCode: "43235",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("submittedStateRevision", () => {
|
describe("submittedStateRevision", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue