diff --git a/src/components/bannerMessages/__tests__/__snapshots__/bannerMessages.test.js.snap b/src/components/bannerMessages/__tests__/__snapshots__/bannerMessages.test.js.snap
new file mode 100644
index 000000000..6787abd4c
--- /dev/null
+++ b/src/components/bannerMessages/__tests__/__snapshots__/bannerMessages.test.js.snap
@@ -0,0 +1,62 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`BannerMessages Component should handle closing messages from state: state messages, OFF 1`] = `""`;
+
+exports[`BannerMessages Component should handle closing messages from state: state messages, ON 1`] = `
+
+
+ }
+ key="loremIpsum"
+ title="Lorem ipsum title"
+ variant="info"
+ >
+ Lorem ipsum message
+
+
+`;
+
+exports[`BannerMessages Component should render a non-connected component: non-connected 1`] = `
+
+
+ }
+ key="loremIpsum"
+ title="Lorem ipsum title"
+ variant="info"
+ >
+ Lorem ipsum message
+
+
+`;
+
+exports[`BannerMessages Component should render specific messages when the appMessages prop is used: specific messages, OFF 1`] = `""`;
+
+exports[`BannerMessages Component should render specific messages when the appMessages prop is used: specific messages, ON 1`] = `
+
+
+ }
+ key="loremIpsum"
+ title="Lorem ipsum title"
+ variant="info"
+ >
+ Lorem ipsum message
+
+
+`;
diff --git a/src/components/bannerMessages/__tests__/bannerMessages.test.js b/src/components/bannerMessages/__tests__/bannerMessages.test.js
new file mode 100644
index 000000000..920b17064
--- /dev/null
+++ b/src/components/bannerMessages/__tests__/bannerMessages.test.js
@@ -0,0 +1,76 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { BannerMessages } from '../bannerMessages';
+
+describe('BannerMessages Component', () => {
+ it('should render a non-connected component', () => {
+ const props = {
+ appMessages: {
+ loremIpsum: true
+ },
+ messages: [
+ {
+ id: 'loremIpsum',
+ title: 'Lorem ipsum title',
+ message: 'Lorem ipsum message'
+ }
+ ]
+ };
+ const component = shallow();
+
+ expect(component).toMatchSnapshot('non-connected');
+ });
+
+ it('should render specific messages when the appMessages prop is used', () => {
+ const props = {
+ appMessages: {
+ loremIpsum: false,
+ dolorSit: false
+ },
+ messages: [
+ {
+ id: 'loremIpsum',
+ title: 'Lorem ipsum title',
+ message: 'Lorem ipsum message'
+ },
+ {
+ id: 'dolorSit',
+ title: 'Dolor sit title',
+ message: 'Dolor sit message'
+ }
+ ]
+ };
+ const component = shallow();
+
+ expect(component).toMatchSnapshot('specific messages, OFF');
+
+ component.setProps({
+ ...props,
+ appMessages: {
+ loremIpsum: true,
+ dolorSit: false
+ }
+ });
+ expect(component).toMatchSnapshot('specific messages, ON');
+ });
+
+ it('should handle closing messages from state', () => {
+ const props = {
+ appMessages: {
+ loremIpsum: true
+ },
+ messages: [
+ {
+ id: 'loremIpsum',
+ title: 'Lorem ipsum title',
+ message: 'Lorem ipsum message'
+ }
+ ]
+ };
+ const component = shallow();
+ expect(component).toMatchSnapshot('state messages, ON');
+
+ component.setState({ loremIpsum: true });
+ expect(component).toMatchSnapshot('state messages, OFF');
+ });
+});
diff --git a/src/components/bannerMessages/bannerMessages.js b/src/components/bannerMessages/bannerMessages.js
new file mode 100644
index 000000000..1af3f2bdc
--- /dev/null
+++ b/src/components/bannerMessages/bannerMessages.js
@@ -0,0 +1,101 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Alert, AlertActionCloseButton, AlertVariant } from '@patternfly/react-core';
+import { connect, reduxSelectors } from '../../redux';
+
+/**
+ * Render banner messages.
+ *
+ * @augments React.Component
+ */
+class BannerMessages extends React.Component {
+ state = {};
+
+ /**
+ * Apply messages' configuration to alerts.
+ *
+ * @returns {Node}
+ */
+ renderAlerts() {
+ const { state } = this;
+ const { appMessages, messages } = this.props;
+ const updatedMessages = [];
+
+ if (messages.length) {
+ Object.entries(appMessages).forEach(([key, value]) => {
+ if (state[key] !== true && value === true) {
+ const message = messages.find(({ id }) => id === key);
+
+ if (message) {
+ updatedMessages.push({
+ key,
+ ...message
+ });
+ }
+ }
+ });
+ }
+
+ return updatedMessages.map(({ key, message, title, variant = AlertVariant.info }) => {
+ const actionClose = this.setState({ [key]: true })} />;
+
+ return (
+
+ {message}
+
+ );
+ });
+ }
+
+ /**
+ * Render a banner messages container.
+ *
+ * @returns {Node}
+ */
+ render() {
+ const alerts = this.renderAlerts();
+
+ if (alerts.length) {
+ return {alerts}
;
+ }
+
+ return null;
+ }
+}
+
+/**
+ * Prop types.
+ *
+ * @type {{appMessages: object, messages: Array}}
+ */
+BannerMessages.propTypes = {
+ appMessages: PropTypes.object.isRequired,
+ messages: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ title: PropTypes.node.isRequired,
+ message: PropTypes.node.isRequired,
+ variant: PropTypes.oneOf([...Object.values(AlertVariant)])
+ })
+ )
+};
+
+/**
+ * Default props.
+ *
+ * @type {{messages: Array}}
+ */
+BannerMessages.defaultProps = {
+ messages: []
+};
+
+/**
+ * Create a selector from applied state, props.
+ *
+ * @type {Function}
+ */
+const makeMapStateToProps = reduxSelectors.appMessages.makeAppMessages();
+
+const ConnectedBannerMessages = connect(makeMapStateToProps)(BannerMessages);
+
+export { ConnectedBannerMessages as default, ConnectedBannerMessages, BannerMessages };
diff --git a/src/components/openshiftView/__tests__/__snapshots__/openshiftView.test.js.snap b/src/components/openshiftView/__tests__/__snapshots__/openshiftView.test.js.snap
index c50a632e4..c867beff7 100644
--- a/src/components/openshiftView/__tests__/__snapshots__/openshiftView.test.js.snap
+++ b/src/components/openshiftView/__tests__/__snapshots__/openshiftView.test.js.snap
@@ -8,6 +8,14 @@ exports[`OpenshiftView Component should display an alternate graph on query-stri
>
t(curiosity-view.title, {"appName":"Subscription Watch","context":"OpenShift"})
+
+
+
@@ -163,6 +171,14 @@ exports[`OpenshiftView Component should have a fallback title: title 1`] = `
>
t(curiosity-view.title, {"appName":"Subscription Watch","context":"OpenShift"})
+
+
+
@@ -735,6 +751,14 @@ exports[`OpenshiftView Component should render a non-connected component: non-co
>
t(curiosity-view.title, {"appName":"Subscription Watch","context":"OpenShift"})
+
+
+
diff --git a/src/components/openshiftView/openshiftView.js b/src/components/openshiftView/openshiftView.js
index c9ba9eef8..06b7e4bb8 100644
--- a/src/components/openshiftView/openshiftView.js
+++ b/src/components/openshiftView/openshiftView.js
@@ -6,7 +6,7 @@ import {
} from '@patternfly/react-tokens';
import { Button, Label as PfLabel } from '@patternfly/react-core';
import { DateFormat } from '@redhat-cloud-services/frontend-components/components/cjs/DateFormat';
-import { PageLayout, PageHeader, PageSection, PageToolbar } from '../pageLayout/pageLayout';
+import { PageLayout, PageHeader, PageMessages, PageSection, PageToolbar } from '../pageLayout/pageLayout';
import {
RHSM_API_QUERY_GRANULARITY_TYPES as GRANULARITY_TYPES,
RHSM_API_QUERY_SORT_DIRECTION_TYPES as SORT_DIRECTION_TYPES,
@@ -20,6 +20,7 @@ import C3GraphCard from '../c3GraphCard/c3GraphCard';
import { Select } from '../form/select';
import Toolbar from '../toolbar/toolbar';
import InventoryList from '../inventoryList/inventoryList';
+import BannerMessages from '../bannerMessages/bannerMessages';
import { helpers } from '../../common';
import { translate } from '../i18n/i18n';
@@ -136,6 +137,9 @@ class OpenshiftView extends React.Component {
{t(`curiosity-view.title`, { appName: helpers.UI_DISPLAY_NAME, context: productLabel })}
+
+
+
t(curiosity-view.title, {"appName":"Subscription Watch","context":"RHEL"})
+
+
+
@@ -148,6 +156,14 @@ exports[`RhelView Component should have a fallback title: title 1`] = `
>
t(curiosity-view.title, {"appName":"Subscription Watch","context":"RHEL"})
+
+
+
@@ -677,6 +693,14 @@ exports[`RhelView Component should render a non-connected component: non-connect
>
t(curiosity-view.title, {"appName":"Subscription Watch","context":"RHEL"})
+
+
+
diff --git a/src/components/rhelView/rhelView.js b/src/components/rhelView/rhelView.js
index f8f5da034..a553c54ba 100644
--- a/src/components/rhelView/rhelView.js
+++ b/src/components/rhelView/rhelView.js
@@ -10,7 +10,7 @@ import {
} from '@patternfly/react-tokens';
import { Button, Label as PfLabel } from '@patternfly/react-core';
import { DateFormat } from '@redhat-cloud-services/frontend-components/components/cjs/DateFormat';
-import { PageLayout, PageHeader, PageSection, PageToolbar } from '../pageLayout/pageLayout';
+import { PageLayout, PageHeader, PageMessages, PageSection, PageToolbar } from '../pageLayout/pageLayout';
import {
RHSM_API_QUERY_SORT_DIRECTION_TYPES as SORT_DIRECTION_TYPES,
RHSM_API_QUERY_GRANULARITY_TYPES as GRANULARITY_TYPES,
@@ -22,6 +22,7 @@ import GraphCard from '../graphCard/graphCard';
import C3GraphCard from '../c3GraphCard/c3GraphCard';
import Toolbar from '../toolbar/toolbar';
import InventoryList from '../inventoryList/inventoryList';
+import BannerMessages from '../bannerMessages/bannerMessages';
import { helpers } from '../../common';
import { translate } from '../i18n/i18n';
@@ -63,6 +64,9 @@ class RhelView extends React.Component {
{t(`curiosity-view.title`, { appName: helpers.UI_DISPLAY_NAME, context: productLabel })}
+
+
+
{
+ it('should return specific selectors', () => {
+ expect(appMessagesSelectors).toMatchSnapshot('selectors');
+ });
+
+ it('should pass minimal data on missing a reducer response', () => {
+ const state = {};
+ expect(appMessagesSelectors.appMessages(state)).toMatchSnapshot('missing reducer error');
+ });
+
+ it('should pass minimal data on a product ID without a product ID provided', () => {
+ const props = {
+ viewId: 'test',
+ productId: undefined
+ };
+ const state = {};
+
+ expect(appMessagesSelectors.appMessages(state, props)).toMatchSnapshot('no product id error');
+ });
+});
diff --git a/src/redux/selectors/appMessagesSelectors.js b/src/redux/selectors/appMessagesSelectors.js
new file mode 100644
index 000000000..9172f2509
--- /dev/null
+++ b/src/redux/selectors/appMessagesSelectors.js
@@ -0,0 +1,54 @@
+import { createSelector } from 'reselect';
+/**
+ * Selector cache.
+ *
+ * @private
+ * @type {{data: {object}}}
+ */
+const selectorCache = { data: {} };
+
+/**
+ * Return a combined state, props object.
+ *
+ * @private
+ * @param {object} state
+ * @param {object} props
+ * @returns {object}
+ */
+const statePropsFilter = (state, props = {}) => ({
+ viewId: props.viewId,
+ productId: props.productId
+});
+
+/**
+ * Create selector, transform combined state, props into a consumable object.
+ *
+ * @type {{appMessages: object}}
+ */
+const selector = createSelector([statePropsFilter], data => {
+ const { viewId = null, productId = null } = data || {};
+ const appMessages = {};
+
+ const cache = (viewId && productId && selectorCache.data[`${viewId}_${productId}`]) || undefined;
+
+ Object.assign(appMessages, { ...cache });
+
+ return { appMessages };
+});
+
+/**
+ * Expose selector instance. For scenarios where a selector is reused across component instances.
+ *
+ * @param {object} defaultProps
+ * @returns {{appMessages: object}}
+ */
+const makeSelector = defaultProps => (state, props) => ({
+ ...selector(state, props, defaultProps)
+});
+
+const appMessagesSelectors = {
+ appMessages: selector,
+ makeAppMessages: makeSelector
+};
+
+export { appMessagesSelectors as default, appMessagesSelectors, selector, makeSelector };
diff --git a/src/redux/selectors/index.js b/src/redux/selectors/index.js
index 65bceff05..c0195a665 100644
--- a/src/redux/selectors/index.js
+++ b/src/redux/selectors/index.js
@@ -1,3 +1,4 @@
+import appMessagesSelectors from './appMessagesSelectors';
import guestsListSelectors from './guestsListSelectors';
import graphCardSelectors from './graphCardSelectors';
import inventoryListSelectors from './inventoryListSelectors';
@@ -5,6 +6,7 @@ import userSelectors from './userSelectors';
import viewSelectors from './viewSelectors';
const reduxSelectors = {
+ appMessages: appMessagesSelectors,
guestsList: guestsListSelectors,
graphCard: graphCardSelectors,
inventoryList: inventoryListSelectors,
diff --git a/src/styles/_banner-messages.scss b/src/styles/_banner-messages.scss
new file mode 100644
index 000000000..4cca2f8bb
--- /dev/null
+++ b/src/styles/_banner-messages.scss
@@ -0,0 +1,5 @@
+.curiosity-banner-messages {
+ padding-left: var(--pf-c-page__main-section--PaddingLeft);
+ padding-right: var(--pf-c-page__main-section--PaddingRight);
+ padding-bottom: var(--pf-global--spacer--md);
+}
diff --git a/src/styles/index.scss b/src/styles/index.scss
index 5263e8ea4..1a8c325fa 100644
--- a/src/styles/index.scss
+++ b/src/styles/index.scss
@@ -18,3 +18,4 @@
@import 'form';
@import 'toolbar';
@import 'inventory-list';
+@import 'banner-messages';