Added alert component
This commit is contained in:
parent
a2bceecd79
commit
bca83d4783
4 changed files with 472 additions and 5 deletions
|
|
@ -1,14 +1,37 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="site-header d-flex justify-content-center align-items-center flex-column">
|
<div>
|
||||||
<img id="siteHeaderImage" class="img-fluid" :src="imageSrc" :alt="altText" />
|
<div class="site-header d-flex justify-content-center align-items-center flex-column">
|
||||||
<menuModal/>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import menuModal from "@/common-components/site-header/menu-modal/menu-modal";
|
import menuModal from "@/common-components/site-header/menu-modal/menu-modal";
|
||||||
|
import alert from "@/ux-components/alert/alert";
|
||||||
|
|
||||||
export default({
|
export default({
|
||||||
name: "siteHeader",
|
name: "siteHeader",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
displayGlobalAlert: false,
|
||||||
|
globalAlertMessage: {
|
||||||
|
isDismissible: false,
|
||||||
|
messageCopy: "",
|
||||||
|
messageHeadline: "",
|
||||||
|
type: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
cmsWidgetName: String,
|
cmsWidgetName: String,
|
||||||
},
|
},
|
||||||
|
|
@ -23,7 +46,8 @@ import menuModal from "@/common-components/site-header/menu-modal/menu-modal";
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
menuModal
|
menuModal,
|
||||||
|
alert
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -118,4 +118,36 @@ function mapStringToState(str) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return stringBuilder.trimStart();
|
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 !== "");
|
||||||
|
}
|
||||||
|
|
|
||||||
222
src/ux-components/alert/alert.spec.js
Normal file
222
src/ux-components/alert/alert.spec.js
Normal 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;
|
||||||
|
}
|
||||||
189
src/ux-components/alert/alert.vue
Normal file
189
src/ux-components/alert/alert.vue
Normal 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>
|
||||||
Loading…
Reference in a new issue