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';