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>
|
||||
<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";
|
||||
|
||||
export default({
|
||||
name: "siteHeader",
|
||||
data() {
|
||||
return {
|
||||
displayGlobalAlert: false,
|
||||
globalAlertMessage: {
|
||||
isDismissible: false,
|
||||
messageCopy: "",
|
||||
messageHeadline: "",
|
||||
type: "",
|
||||
},
|
||||
};
|
||||
},
|
||||
props: {
|
||||
cmsWidgetName: String,
|
||||
},
|
||||
|
|
@ -23,7 +46,8 @@ import menuModal from "@/common-components/site-header/menu-modal/menu-modal";
|
|||
}
|
||||
},
|
||||
components: {
|
||||
menuModal
|
||||
menuModal,
|
||||
alert
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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 !== "");
|
||||
}
|
||||
|
|
|
|||
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