Merge branch 'develop' into feature/Digital/SSR-51

This commit is contained in:
Jeremy Zimmerman 2022-11-09 12:55:11 -05:00
commit 6f48a901fb
22 changed files with 1162 additions and 41 deletions

View file

@ -152,7 +152,7 @@ describe("dropdownQuestion.vue", () => {
const wrapper = shallowMount(dropdownQuestion, {
propsData: {
options: {},
modelValue: 0,
modelValue: "0",
},
mixins: [mockMixin]
});

View file

@ -37,6 +37,8 @@ export default {
disableAutoFill: Boolean,
validationRules: String,
cmsWidgetName: String,
hasError: Boolean,
errors: Array
},
setup(props) {
const propsClone = Object.assign({}, props);

View file

@ -0,0 +1,42 @@
import { mount } from "@vue/test-utils";
import siteFooter from "./site-footer";
describe("site-footer.vue", () => {
it("Should emit ForwardClicked on button click", async () => {
// Act
const wrapper = mount(siteFooter, {
mixins: [mockMixin],
});
wrapper.vm.buttonClick();
// Assert
expect(wrapper.emitted()["ForwardClicked"][0]).toHaveBeenCalled;
});
it("Should emit BackClicked on link click", async () => {
// Act
const wrapper = mount(siteFooter, {
mixins: [mockMixin],
});
wrapper.vm.linkClick();
// Assert
expect(wrapper.emitted()["BackClicked"][0]).toHaveBeenCalled;
});
it("Should change button text when update button text is called", async () => {
// Act
const wrapper = mount(siteFooter, {
mixins: [mockMixin],
});
wrapper.vm.updateButtonText("newText");
// Assert
expect(wrapper.componentVM.customButtontext).toBe("newText");
});
});
const mockMixin = {
methods: {
getCmsContent: jest.fn(),
getFooterInfoBoxHeight: jest.fn(() => 80),
},
};

View file

@ -0,0 +1,132 @@
<template>
<div class="row" :style="`padding-bottom: ${paddingHeight}px`"></div>
<footer class="footer container-fluid fixed-bottom g-5 bg-light py-4" id="infoBox">
<div class="row d-flex flex-row-reverse align-items-center vw-100">
<div class="col button-col d-flex" id="stacked">
<buttonMain
ref="buttonMain"
isPrimary
:buttonText="buttonText"
loaderColor="white"
:class="isForwardActionDisabled && 'form-test-invalid'"
:aria-disabled="isForwardActionDisabled"
:isDisabled="isForwardActionDisabled"
@click-event="buttonClick"
data-bs-target="#footerModal"
data-bs-dismiss="modal" />
</div>
<div v-if="!isBackButtonHidden" class="col-auto link-col py-1 text-break">
<textLink
linkType="navigation"
:text="backLink"
@click-event="linkClick"
href="javascript:void(0)"
data-bs-target="#footerModal"
data-bs-dismiss="modal" />
</div>
</div>
</footer>
</template>
<script>
import textLink from "@/ux-components/text-link/text-link";
import buttonMain from "@/ux-components/button-main/button-main";
export default {
name: "funnelFooter",
props: {
isForwardActionDisabled: Boolean,
isBackButtonHidden: { type: Boolean, default: false },
cmsWidgetName: String,
},
components: {
textLink,
buttonMain,
},
data() {
return {
paddingHeight: 0,
customButtontext: "",
};
},
mounted() {
this.paddingHeight = this.getFooterInfoBoxHeight() + 24;
this.$nextTick(() => {
window.addEventListener("resize", this.onResize);
});
},
beforeUnmount() {
window.removeEventListener("resize", this.onResize);
},
unmounted() {
document.onkeydown = null;
},
computed: {
backLink() {
return this.getCmsContent(this.cmsWidgetName, "BackButtonText");
},
buttonText() {
return this.customButtontext
? this.customButtontext
: this.getCmsContent(this.cmsWidgetName, "ForwardButtonText");
},
},
methods: {
onResize() {
this.paddingHeight = this.getFooterInfoBoxHeight();
},
updateButtonText(newText) {
this.customButtontext = newText;
},
removeLoader() {
this.$refs.buttonMain.removeLoader();
document.onkeydown = function (e) {
return true;
};
},
buttonClick() {
//prevent keyboard input after button click
document.onkeydown = function (e) {
return false;
};
this.$emit("ForwardClicked");
},
linkClick() {
this.$emit("BackClicked");
},
},
};
</script>
<style lang="scss" scoped>
.footer {
display: flex;
a {
display: flex;
justify-content: center;
}
.col,
.col-auto,
.col button {
width: 100%;
}
@media only screen and (min-width: 340px) {
.col,
.col-auto,
.col button {
width: auto;
justify-content: flex-end;
}
.btn-primary {
width: auto;
}
a {
display: flex;
justify-content: flex-start;
}
}
& > .container-fluid {
overflow-x: visible; // needed to fix hidden footer on some iphones
}
}
</style>

View file

@ -1,25 +1,25 @@
import siteHeader from "@/common-components/site-header/site-header";
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
describe("site-header", () => {
test("renders the logo image", () => {
const wrapper = shallowMount(siteHeader, {
setProps: {
cmsWidgetName: "header",
},
mixins: [mockMixin]
});
const wrapper = setupMocks({mountOptionsMockData: {}});
expect(wrapper.find("img")).toBeTruthy();
wrapper.unmount();
});
});
const mockMixin = {
methods: {
getCmsContent: jest.fn()
}
function setupMocks({
mountOptionsMockData = {},
}) {
const mountOptions = getMountOptions(mountOptionsMockData);
const wrapper = shallowMount(siteHeader, mountOptions);
return wrapper;
}

View file

@ -1,14 +1,39 @@
<template>
<div class="site-header d-flex justify-content-center align-items-center flex-column">
<img id="siteHeaderImage" class="img-fluid" :src="imageSrc" :alt="altText" />
<menuModal/>
<div>
<div class="site-header d-flex justify-content-center align-items-center flex-column">
<img id="siteHeaderImage" class="img-fluid" :src="imageSrc" :alt="altText" />
<menuModal/>
</div>
<alert
v-if="displayGlobalAlert"
class="position-absolute rounded-0 w-100 border-0 shadow-sm start-0"
cmsWidgetName="GlobalAlert"
:manualHeadline="globalAlertMessage.messageHeadline"
:manualCopy="globalAlertMessage.messageCopy"
:alertClass="globalAlertMessage.type"
v-bind:isDismissible="globalAlertMessage.isDismissible" />
</div>
</template>
<script>
import menuModal from "@/common-components/site-header/menu-modal/menu-modal";
import alert from "@/ux-components/alert/alert";
import eventBus from "@/helpers/event-bus/event-bus";
import { globalEvents } from "@/constants/events";
export default({
name: "siteHeader",
data() {
return {
displayGlobalAlert: false,
globalAlertMessage: {
isDismissible: false,
messageCopy: "",
messageHeadline: "",
type: "",
},
};
},
props: {
cmsWidgetName: String,
},
@ -22,8 +47,21 @@ import menuModal from "@/common-components/site-header/menu-modal/menu-modal";
return this.getCmsContent(this.cmsWidgetName, "AltText")
}
},
mounted() {
// Check if alert event is on the bus
const alertEvent = eventBus.readAndPopEventFromBus(
globalEvents.Categories.GLOBAL_ALERT,
globalEvents.SubCategories.PAGE_NOT_FOUND
);
// If alert event is on the bus, then display the alert
if (alertEvent !== undefined) {
this.displayGlobalAlert = true;
this.globalAlertMessage = alertEvent;
}
},
components: {
menuModal
menuModal,
alert
},
})
</script>

View file

@ -55,6 +55,7 @@ export default {
validationRules: String,
cmsWidgetName: String,
maxLength: String,
disableAutoFill: Boolean,
},
setup(props) {
const propsClone = Object.assign({}, props);

View file

@ -9,7 +9,6 @@
</template>
<script>
import { useMainStore } from "@/store";
export default {
name: "vehicle-banner",
props: {
@ -25,7 +24,7 @@ export default {
return this.genericVehicleImage;
}
const imageUrl = useMainStore().order.vehicle.imageUrl;
const imageUrl = this.mainStore.order.vehicle.imageUrl;
if (!imageUrl || imageUrl === "NULL"){
return this.getUnmatchedVehicleIcon();
}
@ -53,7 +52,7 @@ export default {
},
methods: {
getUnmatchedVehicleIcon(){
switch(useMainStore().order.vehicle.category){
switch(this.mainStore.order.vehicle.category){
case this.vehicleCategories.CAR:
return this.carUnmatchedVehicleIcon;
case this.vehicleCategories.SUV:

17
src/constants/events.js Normal file
View file

@ -0,0 +1,17 @@
const globalEvents = {
Categories: {
GLOBAL_ALERT: "GLOBAL_ALERT",
},
SubCategories: {
PAGE_NOT_FOUND: "PAGE_NOT_FOUND",
},
};
const globalEventTypes = {
Success: "alert-success",
Warning: "alert-warning",
Info: "alert-info",
Danger: "alert-danger",
};
export { globalEvents, globalEventTypes };

View file

@ -118,4 +118,36 @@ function mapStringToState(str) {
}
return stringBuilder.trimStart();
}
}
export function doesCopyContainRouterLink(copy) {
return copy.includes(this.dynamicStrings.ROUTER_LINK);
}
export function splitCopyOnCMSPlaceHolder(copy) {
// splits copy on { ... } such as {routerlink: ...}
return copy.split(/{(.*?)}/g);
}
export function getRouterLinkRouteFromCopy(copy) {
// sample input: {routerLink:estimate,provide your VIN}
// first split would return 'estimate,provide your VIN'
// second split would return 'estimate'
return copy.split(":")[1].split(",")[0];
}
export function getRouterLinkDisplayTextFromCopy(copy) {
// sample input: {routerLink:estimate,provide your VIN}
// first split would return 'estimate,provide your VIN'
// second split would return 'provide your VIN'
return copy.split(":")[1].split(",")[1];
}
// Copy returned from the CMS that has newlines will return blocks wrapped in
// <p ... >...</p>
// This function returns an array of each paragraph, works with or without html
// attributes present
export function splitCMSCopyOnParagraphTag(copy) {
// filter removes empty strings that are a result of string.split with regex
return copy.split(/(?:<p(?:.*?)>)|(?:<\/p>)/g).filter((paragraph) => paragraph !== "");
}

View file

@ -0,0 +1,35 @@
import { useMainStore } from '@/store';
export default {
// Adds event to the bus given its category, subcategory, and eventValue;
addEventToBus(category, subCategory, eventValue) {
useMainStore().addEventToBus( {
category: category,
subCategory: subCategory,
eventValue: eventValue,
});
},
// Finds event on the bus, removes the item, and returns its value to the caller.
readAndPopEventFromBus(category, subCategory) {
const event = useMainStore().eventBusItem(category, subCategory);
if(event) {
useMainStore().removeEventFromBus( {
category: category,
subCategory: subCategory,
});
}
return event;
},
// Finds the event on the bus and returns its value to the caller, does not remove it.
readEventFromBus(category, subCategory) {
const event = useMainStore().eventBusItem(category, subCategory);
return event;
},
};

View file

@ -0,0 +1,78 @@
import { globalEvents, globalEventTypes } from "@/constants/events";
import eventBus from "@/helpers/event-bus/event-bus";
import { useMainStore } from '@/store';
import { createTestingPinia } from '@pinia/testing';
const pinia = createTestingPinia();
useMainStore(pinia);
useMainStore().addEventToBus = jest.fn();
useMainStore().removeEventFromBus = jest.fn();
useMainStore().eventBusItem = jest.fn();
describe("event-bus.js", () => {
let event = {
isDismissible: true,
messageCopy: "You can get a quote by starting on this page.",
messageHeadline: "We're sorry, something went wrong.",
type: globalEventTypes.Danger,
};
afterEach(() => {
jest.resetAllMocks();
})
it("removes items when readandpop is called", () => {
useMainStore().eventBusItem.mockReturnValueOnce(event);
const eventValue = eventBus.readAndPopEventFromBus(
globalEvents.Categories.GLOBAL_ALERT,
globalEvents.SubCategories.PAGE_NOT_FOUND
);
expect(useMainStore().eventBusItem).toBeCalledTimes(1);
expect(useMainStore().removeEventFromBus).toBeCalledTimes(1);
});
it("doesn't try to remove items when readandpop is called and item doesn't exist", () => {
useMainStore().eventBusItem.mockReturnValueOnce(undefined);
const eventValue = eventBus.readAndPopEventFromBus(
globalEvents.Categories.GLOBAL_ALERT,
globalEvents.SubCategories.PAGE_NOT_FOUND
);
expect(useMainStore().eventBusItem).toBeCalledTimes(1);
expect(useMainStore().removeEventFromBus).toBeCalledTimes(0);
});
it("returns event from bus", () => {
useMainStore().eventBusItem.mockReturnValueOnce(event);
const eventValue = eventBus.readEventFromBus(
globalEvents.Categories.GLOBAL_ALERT,
globalEvents.SubCategories.PAGE_NOT_FOUND
);
expect(eventValue).toBe(event);
});
it("Reads event from bus, should have event value.", () => {
// Arrange / Act
eventBus.addEventToBus(
globalEvents.Categories.GLOBAL_ALERT,
globalEvents.SubCategories.PAGE_NOT_FOUND,
event
);
expect(useMainStore().addEventToBus).toHaveBeenCalled();
});
});

View file

@ -1,11 +0,0 @@
<template>
<div>
404: Page Not Found
</div>
</template>
<script>
export default {
name: 'error-404'
}
</script>

View file

@ -117,13 +117,13 @@ function setupMocks({
//Mock api responses
const apiResponses = {
cmsContent: {
FunnelSubHeaderWidget: pageHeaderWidgetHeaderText,
SiteSubHeaderWidget: pageHeaderWidgetHeaderText,
VehicleModelQuestion: buttonQuestionContent,
VehicleBannerWidget: {
GenericVehicleImage:
"https://digitalisscms.dev.safelite.io/images/default-source/default-album/blurred-image.jpg",
},
FunnelHeaderWidget: {
SiteHeaderWidget: {
LogoImage:
"https://digitalisscms.dev.safelite.io/images/default-source/default-album/logos/insuranceLogo.jpg",
},

View file

@ -3,6 +3,8 @@ import { lazyLoadComponent } from "@/router/dynamic-routing/component-loader";
import {issPageValues} from '@/router/router-constants/issPage-values';
import { routingTable } from "@/router/router-constants/routing-table";
import { useMainStore } from '@/store';
import eventBus from "@/helpers/event-bus/event-bus";
import { globalEvents, globalEventTypes } from "@/constants/events";
import analyticsMixin from "@/mixins/analytics-mixin";
@ -156,13 +158,25 @@ function getNavigationMap (scenario, currentRoute) {
};
function GoToStartOn404(next) {
const errorPageName = issPageValues.ERROR_404;
const errorPageName = issPageValues.VEHICLE_YEAR;
router.addRoute({
path: "/",
name: errorPageName,
component: lazyLoadComponent(errorPageName),
});
// Put item on the bus
eventBus.addEventToBus(
globalEvents.Categories.GLOBAL_ALERT,
globalEvents.SubCategories.PAGE_NOT_FOUND,
{
isDismissible: true,
messageCopy: "You can get a quote by starting on this page.",
messageHeadline: "We're sorry, something went wrong.",
type: globalEventTypes.Danger,
}
);
next({
name: errorPageName,
query: {issPage: errorPageName },

View file

@ -1,5 +1,4 @@
export const issPageValues = {
ERROR_404: "error-404",
WELCOME_PAGE: "welcome-page",
VEHICLE_MAKE: "vehicle-make",
VEHICLE_YEAR: "vehicle-year",

View file

@ -56,6 +56,17 @@ export const state = getDefaultState();
export const useMainStore = defineStore({
id: storeId,
state: () => state,
getters: {
eventBusItem: (state) => ( eventCategory, eventSubCategory) => {
const matchedEvent = state.applicationUser.eventBus.find(
( { category, subCategory } ) =>
category === eventCategory && subCategory === eventSubCategory
);
return matchedEvent?.eventValue;
},
eventBus: (state) => state.applicationUser.eventBus,
},
getters: {
applicationUserObj: (state) => state.applicationUser,
pageData: (state) => (page) => {
@ -210,6 +221,21 @@ export const useMainStore = defineStore({
this.order.vehicle.style = style;
}
},
addEventToBus (event) {
this.applicationUser.eventBus.push(event);
},
removeEventFromBus (eventData) {
const matchedEvent = this.applicationUser.eventBus.find(
( {category, subCategory } ) =>
category === eventData.category && subCategory === eventData.subCategory
);
const itemIndex = this.applicationUser.eventBus.indexOf(matchedEvent);
// If the item exists, remove it.
if (itemIndex > -1) {
this.applicationUser.eventBus.splice(itemIndex, 1);
}
},
// populate initial state
populateInitialState()

View file

@ -3,19 +3,83 @@ import { createApp } from 'vue';
import { createPinia } from "pinia";
import App from '@/App.vue';
describe("Store", () => {
let store = null;
beforeAll(() => {
const vueApp = createApp(App);
const pinia = createPinia();
vueApp.use(pinia);
describe("Store", () => {
let store;
const vueApp = createApp(App);
const pinia = createPinia();
vueApp.use(pinia);
beforeEach(() => {
store = useMainStore();
store.applicationUser.eventBus = [];
jest.resetAllMocks();
})
it("Should Store Vehicle Year", () => {
let testYear = "2001";
store.updateVehicleYear(testYear);
expect(store.order.vehicle.year).toEqual(testYear);
});
it("Should add events to the bus", () => {
let event = {
category: "TestCategory",
subCategory: "TestSubCategory",
eventValue: {
isDismissible: true,
messageCopy: "You can get a quote by starting on this page.",
messageHeadline: "We're sorry, something went wrong.",
type: "testType",
},
};
store.addEventToBus(event);
expect(store.applicationUser.eventBus[0]).toEqual(event);
});
it("Should remove events from the bus", () => {
let event = {
category: "TestCategory",
subCategory: "TestSubCategory",
eventValue: {
isDismissible: true,
messageCopy: "You can get a quote by starting on this page.",
messageHeadline: "We're sorry, something went wrong.",
type: "testType",
},
};
store.addEventToBus(event);
expect(store.applicationUser.eventBus.length).toBe(1);
store.removeEventFromBus({ category: event.category, subCategory: event.subCategory })
expect(store.applicationUser.eventBus.length).toBe(0);
});
it("Should return correct event using the getter function eventBusItem", () => {
let event = {
category: "TestCategory",
subCategory: "TestSubCategory",
eventValue: {
isDismissible: true,
messageCopy: "You can get a quote by starting on this page.",
messageHeadline: "We're sorry, something went wrong.",
type: "testType",
},
};
store.addEventToBus(event);
const actual = store.eventBusItem(event.category, event.subCategory)
expect(actual).toEqual(event.eventValue);
});
});

View file

@ -0,0 +1,222 @@
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import alert from "./alert";
describe("alert.vue", () => {
it("Should add class 'alert-dismissible' if isDismissible is true", async () => {
// Arrange
const wrapper = shallowMount(
alert,
setupMocks({
propsData: {
isDismissible: true,
manualHeadline: "testHeader",
manualCopy: "testCopy",
},
})
);
const wrapperDiv = wrapper.find("div");
// Assert
expect(wrapperDiv.classes()).toContain("alert-dismissible");
});
it("Should add specified alert class", async () => {
// Arrange
const wrapper = shallowMount(
alert,
setupMocks({
propsData: {
alertClass: "warning",
manualHeadline: "testHeader",
manualCopy: "testCopy",
},
})
);
const wrapperDiv = wrapper.find("div");
// Assert
expect(wrapperDiv.classes()).toContain("warning");
});
it("Should update alert Headline to manualHeadline datam entered and alert copy to manualCopy datam entered when no cmsWidgetName entered", async () => {
// Arrange
const wrapper = shallowMount(alert, setupMocks({}));
// Assert
expect(wrapper.vm.alertHeadline).toBe("testHeader");
expect(wrapper.vm.alertCopy).toBe("testCopy");
});
it("Should container a <router-link> tag if the manualCopy contains a {routerLink: testName, testLink} placeholder", () => {
// Arrange & Act
const wrapper = shallowMount(
alert,
setupMocks({
propsData: {
manualHeadline: "testHeader",
manualCopy: "testCopy with a {routerLink: testName, testLink} inside of it",
},
stubs: ["router-link"],
})
);
// Assert
expect(wrapper.find("router-link").exists()).toBe(true);
});
it("Should contain 'n+1' <p> tags if the body copy has 'n' <p> tags", () => {
// Arrange & Act
const wrapper = shallowMount(
alert,
setupMocks({
propsData: {
manualHeadline: "testHeader",
manualCopy:
"<p>testCopy with a {routerLink: testName, testLink} inside of it</p><p>and two paragraphs</p>",
},
stubs: ["router-link"],
})
);
// Assert
expect(wrapper.findAll("p").length === 3).toBe(true);
});
it("Should call scrollIntoView() when the clientBoundingRect is not entirely in the viewport (out of view top)", () => {
// Arrange
var viewPortHeight = 200;
setUpViewPort(viewPortHeight);
Element.prototype.getBoundingClientRect = jest.fn(() => {
return { top: -100, bottom: 200 };
});
var mockScrollIntoView = jest.fn();
Element.prototype.scrollIntoView = mockScrollIntoView;
// Act
const wrapper = shallowMount(alert, setupMocks({}));
// Assert
// This is an implementation detail - we just need to test that the final step of snapping
// the window to the alert is working. If using a different function to accomplish that
// just swap this out with the new function
expect(mockScrollIntoView).toHaveBeenCalled();
});
it("Should call scrollIntoView() when the clientBoundingRect is not entirely in the viewport (bottom is hidden behind footer)", () => {
// Arrange
var viewPortHeight = 240;
setUpViewPort(viewPortHeight);
Element.prototype.getBoundingClientRect = jest.fn(() => {
return { top: 100, bottom: 200 };
});
var mockScrollIntoView = jest.fn();
Element.prototype.scrollIntoView = mockScrollIntoView;
// Act
const wrapper = shallowMount(alert, setupMocks({}));
// Assert
// This is an implementation detail - we just need to test that the final step of snapping
// the window to the alert is working. If using a different function to accomplish that
// just swap this out with the new function
expect(mockScrollIntoView).toHaveBeenCalled();
});
it("Should not call scrollIntoView() when the clientBoundingRect is not entirely in the viewport but 'shouldScrollToOnMount' is false", () => {
// Arrange
var viewPortHeight = 200;
setUpViewPort(viewPortHeight);
Element.prototype.getBoundingClientRect = jest.fn(() => {
return { top: -100, bottom: 200 };
});
var mockScrollIntoView = jest.fn();
Element.prototype.scrollIntoView = mockScrollIntoView;
// Act
const wrapper = shallowMount(
alert,
setupMocks({
propsData: {
shouldScrollToOnMount: false,
manualHeadline: "testHeader",
manualCopy: "testCopy",
},
})
);
// Assert
// This is an implementation detail - we just need to test that the final step of snapping
// the window to the alert is working. If using a different function to accomplish that
// just swap this out with the new function
expect(mockScrollIntoView).not.toHaveBeenCalled();
});
it("Should not call scrollIntoView() when the clientBoundingRect is entirely in the viewport", () => {
// Arrange
var viewPortHeight = 500;
setUpViewPort(viewPortHeight);
Element.prototype.getBoundingClientRect = jest.fn(() => {
return { top: 100, bottom: 200 };
});
var mockScrollIntoView = jest.fn();
Element.prototype.scrollIntoView = mockScrollIntoView;
// Act
const wrapper = shallowMount(alert, setupMocks({}));
// Assert
// This is an implementation detail - we just need to test that the final step of snapping
// the window to the alert is working. If using a different function to accomplish that
// just swap this out with the new function
expect(mockScrollIntoView).not.toHaveBeenCalled();
});
});
const mockMixin = {
methods: {
getCmsContent: jest.fn(),
getFooterInfoBoxHeight: jest.fn(() => 50),
},
computed: {
dynamicStrings: jest.fn(() => {
return { ROUTER_LINK: "routerLink:" };
}),
},
};
function setUpViewPort(height) {
Object.defineProperty(global.window, "innerHeight", {
writable: true,
configurable: true,
value: height,
});
Object.defineProperty(window.document.documentElement, "clientHeight", {
writable: true,
configurable: true,
value: height,
});
}
function setupMocks(mountOptionsMockData = {}) {
const defaultMountOptions = {
propsData: {
manualHeadline: "testHeader",
manualCopy: "testCopy",
},
mixins: [mockMixin],
};
const baseMountOptions = getMountOptions(
Object.assign(defaultMountOptions, mountOptionsMockData)
);
const allMountOptions = Object.assign(defaultMountOptions, baseMountOptions);
return allMountOptions;
}

View file

@ -0,0 +1,189 @@
<template>
<div
class="alert fade show text-center mb-0 py-2 px-4"
role="alert"
:class="[
isDismissible ? 'alert-dismissible' : '',
this.alertClass,
this.cssClassNameForCmsWidget,
]">
<p class="m-0 fw-bold alert-heading">{{ alertHeadline }}</p>
<template v-for="paragraph in splitAlertCopyForParagraphTag" :key="paragraph">
<p
class="m-0 text-body small"
v-if="!doesCopyContainRouterLink(paragraph)"
v-html="paragraph"></p>
<p class="m-0 text-body small" v-else>
<template v-for="copy in splitCopyOnCMSPlaceHolder(paragraph)" :key="copy">
<span v-if="!doesCopyContainRouterLink(copy)" v-html="copy"></span>
<span v-else>
<router-link
:to="{
query: { fmgPage: `${getRouterLinkRouteFromCopy(copy)}` },
name: 'root',
}"
>{{ getRouterLinkDisplayTextFromCopy(copy) }}</router-link
>
</span>
</template>
</p>
</template>
<button type="button" class="btn-close p-2" data-bs-dismiss="alert" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23.7 23.7" xml:space="preserve">
<path
d="m23.24 2.7-9.15 9.15L23.24 21a1.581 1.581 0 0 1-1.12 2.7c-.42 0-.82-.16-1.12-.46l-9.15-9.15-9.15 9.15c-.3.3-.7.46-1.12.46A1.581 1.581 0 0 1 .46 21l8.47-8.47.68-.68L.46 2.7c-.62-.62-.62-1.62 0-2.24.62-.62 1.62-.62 2.24 0l8.47 8.47.68.68L21 .46a1.57 1.57 0 0 1 2.23 0c.63.62.63 1.62.01 2.24z" />
</svg>
</button>
</div>
</template>
<script>
import {
doesCopyContainRouterLink,
splitCopyOnCMSPlaceHolder,
getRouterLinkRouteFromCopy,
getRouterLinkDisplayTextFromCopy,
splitCMSCopyOnParagraphTag,
} from "@/helpers/cms-content-helper";
export default {
name: "alert",
props: {
isDismissible: Boolean,
/*
alertClass class names:
alert-success (green)
alert-danger (red)
alert-warning (yellow)
alert-info (blue)
*/
alertClass: String,
cmsWidgetName: {
type: String,
default(rawProps) {
if (!rawProps.cmsWidgetName) {
console.log("Error: Missing a CMS Widget Name (required field)");
}
return "widgetUndefined";
},
},
manualHeadline: String,
manualCopy: String,
shouldScrollToOnMount: {
type: Boolean,
default: true,
},
},
computed: {
alertHeadline() {
return this.manualHeadline
? this.manualHeadline
: this.getCmsContent(this.cmsWidgetName, "HeadlineText");
},
alertCopy() {
return this.manualCopy
? this.manualCopy
: this.getCmsContent(this.cmsWidgetName, "BodyText");
},
splitAlertCopyForParagraphTag() {
return splitCMSCopyOnParagraphTag(this.alertCopy);
},
},
methods: {
doesCopyContainRouterLink,
splitCopyOnCMSPlaceHolder,
getRouterLinkRouteFromCopy,
getRouterLinkDisplayTextFromCopy,
ensureAlertIsInViewPort() {
if (this.shouldScrollToOnMount && this.$el.style.display != "none") {
var footerHeight = this.getFooterInfoBoxHeight();
if (!this.isAlertInViewport(footerHeight)) {
this.$el.scrollIntoView(true); // 'true' attempts to scroll element to top of viewport
}
}
},
isAlertInViewport(footerHeight) {
const rect = this.$el.getBoundingClientRect();
return (
rect.top >= 0 &&
// remove footerHeight from window height to avoid items being hidden behind footer
rect.bottom <=
(window.innerHeight - footerHeight ||
document.documentElement.clientHeight - footerHeight)
);
},
},
mounted() {
this.ensureAlertIsInViewPort();
},
};
</script>
<style lang="scss">
.alert {
button {
display: none;
}
.btn-close {
background: none;
opacity: 1;
width: 0.75rem;
height: 0.75rem;
}
&.alert-dismissible {
button {
display: flex;
top: 2px;
right: 2px;
}
}
&.alert-info {
background-color: $blue-100;
.alert-heading {
color: $blue-700;
}
svg {
fill: $blue-700;
width: 1rem;
height: 1rem;
}
}
&.alert-danger {
background-color: $red-100;
.alert-heading {
color: $red-600;
}
svg {
fill: $red-600;
width: 1rem;
height: 1rem;
}
}
&.alert-warning {
background-color: $yellow-100;
.alert-heading {
color: $yellow-600;
}
svg {
fill: $yellow-600;
width: 1rem;
height: 1rem;
}
}
&.alert-success {
background-color: $green-100;
.alert-heading {
color: $green-700;
}
svg {
fill: $green-700;
width: 1rem;
height: 1rem;
}
}
& p {
font-size: 0.875rem;
margin-bottom: 0.25rem !important;
}
}
</style>

View file

@ -0,0 +1,102 @@
import { shallowMount } from "@vue/test-utils";
import buttonMain from "./button-main";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import { nextTick } from "vue";
describe("buttonMain.vue", () => {
it("Should return btn-primary class", async () => {
// Act
const wrapper = shallowMount(
buttonMain,
setupMocks({
propsData: {
isPrimary: true,
},
})
);
// Assert
const button = wrapper.find("button");
// Expect
expect(button.attributes("class")).toContain("btn-primary");
});
it("Should return aria-disabled state", async () => {
// Act
const wrapper = shallowMount(
buttonMain,
setupMocks({
propsData: {
isDisabled: true,
},
})
);
// Assert
const button = wrapper.find("button");
// Expect
expect(button.attributes()["aria-disabled"]).toEqual("true");
});
it("Should return loader color", async () => {
// Act
const wrapper = shallowMount(
buttonMain,
setupMocks({
propsData: {
loaderColor: "blue",
loaderEnabled: true,
},
})
);
// Assert
const label = wrapper.find("label");
wrapper.vm.clicked();
await nextTick();
const loader = wrapper.find("loader-stub");
expect(loader.attributes("class")).toContain("blue");
});
it("Should return loader position", async () => {
// Act
const wrapper = shallowMount(
buttonMain,
setupMocks({
propsData: {
loaderPosition: "right",
loaderEnabled: true,
},
})
);
// Assert
const label = wrapper.find("label");
wrapper.vm.clicked();
await nextTick();
const loader = wrapper.find("loader-stub");
expect(loader.attributes("class")).toContain("right");
});
});
function setupMocks(mountOptionsMockData = {}) {
const defaultMountOptions = { route: { query: { issPage: "page-name" } } };
const baseMountOptions = getMountOptions(
Object.assign(defaultMountOptions, mountOptionsMockData)
);
const allMountOptions = Object.assign(defaultMountOptions, baseMountOptions);
return allMountOptions;
}

View file

@ -0,0 +1,140 @@
<template>
<button
:aria-disabled="isDisabled"
class="btn d-flex align-items-center py-3 px-4 delay"
:class="[
isPrimary ? 'btn-primary' : 'btn-secondary',
isFloat ? 'float-end' : '',
isLoaderDisplayed ? 'has-loader' : '',
]"
@click="clicked">
<span class="m-0">{{ this.buttonText }}</span>
<loader
class="ms-2"
v-if="isLoaderDisplayed"
v-bind:class="[this.loaderColor, this.loaderPosition]" />
</button>
</template>
<script>
import loader from "@/ux-components/loader/loader";
export default {
name: "buttonMain",
props: {
isPrimary: Boolean,
buttonText: String,
isDisabled: Boolean,
loaderColor: String,
loaderPosition: String,
isFloat: Boolean,
},
data() {
return {
isLoaderDisplayed: false,
};
},
methods: {
removeLoader() {
this.isLoaderDisplayed = false;
},
clicked() {
if (!this.isDisabled) {
this.isLoaderDisplayed = true;
this.$emit("click-event");
}
},
},
components: {
loader,
},
};
</script>
<style lang="scss">
.btn {
&.btn-primary {
position: relative;
background: linear-gradient(270deg, $blue 0%, $blue-800 100%);
border: none;
border-radius: $border-radius-lg;
color: $white;
justify-content: center;
font-weight: 500;
@media (hover: hover) {
background: linear-gradient(270deg, $blue 0%, $blue-800 100%);
}
&:focus {
box-shadow: 0 0 0 3px, 0 0 0 5.5px $blue-700;
}
&:focus, // Mouse, touch, stylus focus
&:focus-visible {
// Keyboard focus for accessibility
outline: none;
box-shadow: 0 0 0 3px, 0 0 0 5.5px $blue-700;
color: $white;
background: linear-gradient(270deg, rgba(6, 87, 124, 1) 0%, rgba(6, 87, 124, 1) 100%);
}
&:disabled {
background: $gray-200 !important;
background: linear-gradient(270deg, $gray-200 0%, $gray-200 100%) !important;
color: $gray-600 !important;
font-weight: 400;
height: 48px;
border: none;
border-radius: $border-radius-lg;
cursor: pointer;
pointer-events: all;
}
&.has-loader {
color: $white;
background: $blue-700;
box-shadow: 0 0 0 3px, 0 0 0 5.5px $blue-700;
}
&.delay {
// fixes flicker while transitioning between states
transition: background 0s 0s ease-in-out;
}
}
&.btn-secondary {
position: relative;
background: transparent;
border: 1px solid $blue;
border-radius: $border-radius-lg;
color: $blue;
font-weight: 500;
transition: all 150ms linear;
height: 3rem;
&:hover {
color: $white;
@include blue-gradient;
}
&:focus, // Mouse, touch, stylus focus
&:focus-visible {
// Keyboard focus for accessibility
outline: none;
box-shadow: 0 0 0 3px $white, 0 0 0 5.5px $blue-700;
color: $white;
@include blue-gradient;
}
&:disabled {
background: transparent;
color: $gray-550 !important;
font-weight: 400;
height: 48px;
border: 1px solid $gray-550;
border-radius: $border-radius-lg;
cursor: pointer;
pointer-events: all;
}
&.has-loader {
color: $white;
@include blue-gradient;
}
&.delay {
// fixes flicker while transitioning between states
transition: background 0s 0s ease-in-out;
}
}
}
</style>