From c4900262f293a12122d1a66e1b5195bc0a5bbf82 Mon Sep 17 00:00:00 2001 From: ivaosthu Date: Mon, 11 Mar 2019 21:10:45 +0100 Subject: [PATCH] feat: Customisable UI via config This feature enables overrides of certain UI elements from the API such as setting a different background color for the header. This will make it easier to customise the UI in different environemnt. --- frontend/public/index.html | 2 +- .../show-api-details-component-test.jsx.snap | 61 ++++++++----------- .../show-api-details-component-test.jsx | 18 +++++- .../api/show-api-details-component.jsx | 16 +++-- .../api/show-api-details-container.jsx | 6 +- frontend/src/component/app.jsx | 34 +++-------- .../component/history/history-container.js | 5 +- .../__snapshots__/drawer-test.jsx.snap | 36 +++++------ .../__snapshots__/footer-test.jsx.snap | 12 ++++ frontend/src/component/menu/drawer.jsx | 10 +-- frontend/src/component/menu/footer.jsx | 3 + frontend/src/component/menu/header.jsx | 55 +++++++++++++++++ .../component/user/show-user-container.jsx | 5 +- frontend/src/data/config-api.js | 13 ++++ frontend/src/index.jsx | 3 +- frontend/src/store/index.js | 2 + .../ui-config-store.test.js.snap | 25 ++++++++ .../__tests__/ui-config-store.test.js | 29 +++++++++ frontend/src/store/ui-config/actions.js | 18 ++++++ frontend/src/store/ui-config/index.js | 41 +++++++++++++ 20 files changed, 294 insertions(+), 100 deletions(-) create mode 100644 frontend/src/component/menu/header.jsx create mode 100644 frontend/src/data/config-api.js create mode 100644 frontend/src/store/ui-config/__tests__/__snapshots__/ui-config-store.test.js.snap create mode 100644 frontend/src/store/ui-config/__tests__/ui-config-store.test.js create mode 100644 frontend/src/store/ui-config/actions.js create mode 100644 frontend/src/store/ui-config/index.js diff --git a/frontend/public/index.html b/frontend/public/index.html index 5ccefda223..896733be79 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -6,7 +6,7 @@ - Unleash UI + Unleash - Enterprise ready feature toggles diff --git a/frontend/src/component/api/__tests__/__snapshots__/show-api-details-component-test.jsx.snap b/frontend/src/component/api/__tests__/__snapshots__/show-api-details-component-test.jsx.snap index 3bfed09ff5..468adb035a 100644 --- a/frontend/src/component/api/__tests__/__snapshots__/show-api-details-component-test.jsx.snap +++ b/frontend/src/component/api/__tests__/__snapshots__/show-api-details-component-test.jsx.snap @@ -5,23 +5,13 @@ exports[`renders correctly with details 1`] = ` logo="Unleash 1.1.0" type="bottom" > - - - GitHub - - - - A product by - - FINN.no - - + + (test) + +
+ + We are the best! + `; @@ -30,22 +20,25 @@ exports[`renders correctly with empty api details 1`] = ` logo="Unleash " type="bottom" > - - - GitHub - - - - A product by - - FINN.no - - + + (test) + +
+ + We are the best! + + +`; + +exports[`renders correctly without uiConfig 1`] = ` + + + + +
+
`; diff --git a/frontend/src/component/api/__tests__/show-api-details-component-test.jsx b/frontend/src/component/api/__tests__/show-api-details-component-test.jsx index c712fff910..2e3202cec2 100644 --- a/frontend/src/component/api/__tests__/show-api-details-component-test.jsx +++ b/frontend/src/component/api/__tests__/show-api-details-component-test.jsx @@ -5,14 +5,28 @@ import renderer from 'react-test-renderer'; jest.mock('react-mdl'); +const uiConfig = { + slogan: 'We are the best!', + environment: 'test', +}; + test('renders correctly with empty api details', () => { - const tree = renderer.create().toJSON(); + const tree = renderer + .create() + .toJSON(); expect(tree).toMatchSnapshot(); }); test('renders correctly with details', () => { const tree = renderer - .create() + .create() + .toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('renders correctly without uiConfig', () => { + const tree = renderer + .create() .toJSON(); expect(tree).toMatchSnapshot(); }); diff --git a/frontend/src/component/api/show-api-details-component.jsx b/frontend/src/component/api/show-api-details-component.jsx index c0c83a42ec..6cd81bbd25 100644 --- a/frontend/src/component/api/show-api-details-component.jsx +++ b/frontend/src/component/api/show-api-details-component.jsx @@ -1,10 +1,11 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { FooterSection, FooterLinkList } from 'react-mdl'; +import { FooterSection } from 'react-mdl'; class ShowApiDetailsComponent extends Component { static propTypes = { apiDetails: PropTypes.object.isRequired, + uiConfig: PropTypes.object.isRequired, fetchAll: PropTypes.func.isRequired, }; @@ -14,16 +15,13 @@ class ShowApiDetailsComponent extends Component { render() { const version = this.props.apiDetails.version || ''; + const { slogan, environment } = this.props.uiConfig; + return ( - - - GitHub - - - A product by FINN.no - - + {environment ? `(${environment})` : ''} +
+ {slogan}
); } diff --git a/frontend/src/component/api/show-api-details-container.jsx b/frontend/src/component/api/show-api-details-container.jsx index fccb7319ef..35d681fc66 100644 --- a/frontend/src/component/api/show-api-details-container.jsx +++ b/frontend/src/component/api/show-api-details-container.jsx @@ -8,6 +8,10 @@ const mapDispatchToProps = { const mapStateToProps = state => ({ apiDetails: state.api.toJS(), + uiConfig: state.uiConfig.toJS(), }); -export default connect(mapStateToProps, mapDispatchToProps)(ShowApiDetailsComponent); +export default connect( + mapStateToProps, + mapDispatchToProps +)(ShowApiDetailsComponent); diff --git a/frontend/src/component/app.jsx b/frontend/src/component/app.jsx index b206d32222..d75d5b58a7 100644 --- a/frontend/src/component/app.jsx +++ b/frontend/src/component/app.jsx @@ -1,50 +1,30 @@ -import React, { Component } from 'react'; +import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; -import { Layout, Header, Navigation, Content, Footer, Grid, Cell } from 'react-mdl'; +import { Layout, Content, Footer, Grid, Cell } from 'react-mdl'; import { Route, Redirect, Switch } from 'react-router-dom'; import styles from './styles.scss'; import ErrorContainer from './error/error-container'; - +import Header from './menu/header'; import AuthenticationContainer from './user/authentication-container'; -import ShowUserContainer from './user/show-user-container'; + import ShowApiDetailsContainer from './api/show-api-details-container'; import Features from '../page/features'; -import { DrawerMenu } from './menu/drawer'; + import { FooterMenu } from './menu/footer'; -import Breadcrum from './menu/breadcrumb'; import { routes } from './menu/routes'; -export default class App extends Component { +export default class App extends PureComponent { static propTypes = { location: PropTypes.object.isRequired, match: PropTypes.object.isRequired, }; - componentWillReceiveProps(nextProps) { - if (this.props.location.pathname !== nextProps.location.pathname) { - clearTimeout(this.timer); - this.timer = setTimeout(() => { - const layout = document.querySelector('.mdl-js-layout'); - const drawer = document.querySelector('.mdl-layout__drawer'); - // hack, might get a built in alternative later - if (drawer.classList.contains('is-visible')) { - layout.MaterialLayout.toggleDrawer(); - } - }, 10); - } - } - render() { return (
-
}> - - - -
- +
diff --git a/frontend/src/component/history/history-container.js b/frontend/src/component/history/history-container.js index ee79b6cd61..16879af2bd 100644 --- a/frontend/src/component/history/history-container.js +++ b/frontend/src/component/history/history-container.js @@ -9,6 +9,9 @@ const mapStateToProps = state => { }; }; -const HistoryListContainer = connect(mapStateToProps, { fetchHistory })(HistoryComponent); +const HistoryListContainer = connect( + mapStateToProps, + { fetchHistory } +)(HistoryComponent); export default HistoryListContainer; diff --git a/frontend/src/component/menu/__tests__/__snapshots__/drawer-test.jsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/drawer-test.jsx.snap index ac7b1d6398..8d2dfeff86 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/drawer-test.jsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/drawer-test.jsx.snap @@ -25,7 +25,7 @@ exports[`should render DrawerMenu 1`] = ` > @@ -38,7 +38,7 @@ exports[`should render DrawerMenu 1`] = ` @@ -51,7 +51,7 @@ exports[`should render DrawerMenu 1`] = ` @@ -64,7 +64,7 @@ exports[`should render DrawerMenu 1`] = ` @@ -77,7 +77,7 @@ exports[`should render DrawerMenu 1`] = ` @@ -90,7 +90,7 @@ exports[`should render DrawerMenu 1`] = ` @@ -107,7 +107,7 @@ exports[`should render DrawerMenu 1`] = ` className="navigation" > @@ -118,14 +118,14 @@ exports[`should render DrawerMenu 1`] = ` User documentation - GitHub + GitHub @@ -156,7 +156,7 @@ exports[`should render DrawerMenu with "features" selected 1`] = ` > @@ -183,7 +183,7 @@ exports[`should render DrawerMenu with "features" selected 1`] = ` @@ -196,7 +196,7 @@ exports[`should render DrawerMenu with "features" selected 1`] = ` @@ -209,7 +209,7 @@ exports[`should render DrawerMenu with "features" selected 1`] = ` @@ -222,7 +222,7 @@ exports[`should render DrawerMenu with "features" selected 1`] = ` @@ -239,7 +239,7 @@ exports[`should render DrawerMenu with "features" selected 1`] = ` className="navigation" > @@ -250,14 +250,14 @@ exports[`should render DrawerMenu with "features" selected 1`] = ` User documentation - GitHub + GitHub diff --git a/frontend/src/component/menu/__tests__/__snapshots__/footer-test.jsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/footer-test.jsx.snap index 455e938e14..8a25ea33f1 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/footer-test.jsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/footer-test.jsx.snap @@ -50,6 +50,12 @@ exports[`should render DrawerMenu 1`] = ` > Sign out + + GitHub + Sign out + + GitHub + ( @@ -31,16 +31,16 @@ export const DrawerMenu = () => ( User documentation - GitHub + GitHub diff --git a/frontend/src/component/menu/footer.jsx b/frontend/src/component/menu/footer.jsx index a263c2bf4a..ab8d4895be 100644 --- a/frontend/src/component/menu/footer.jsx +++ b/frontend/src/component/menu/footer.jsx @@ -13,6 +13,9 @@ export const FooterMenu = () => ( {item.title} ))} + + GitHub + diff --git a/frontend/src/component/menu/header.jsx b/frontend/src/component/menu/header.jsx new file mode 100644 index 0000000000..1e6b6fe394 --- /dev/null +++ b/frontend/src/component/menu/header.jsx @@ -0,0 +1,55 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { Header, Navigation } from 'react-mdl'; +import { Route } from 'react-router-dom'; +import { DrawerMenu } from './drawer'; +import Breadcrum from './breadcrumb'; +import ShowUserContainer from '../user/show-user-container'; +import { fetchUIConfig } from './../../store/ui-config/actions'; + +class HeaderComponent extends PureComponent { + static propTypes = { + uiConfig: PropTypes.object.isRequired, + fetchUIConfig: PropTypes.func.isRequired, + location: PropTypes.object.isRequired, + }; + + componentDidMount() { + this.props.fetchUIConfig(); + } + + componentWillReceiveProps(nextProps) { + if (this.props.location.pathname !== nextProps.location.pathname) { + clearTimeout(this.timer); + this.timer = setTimeout(() => { + const layout = document.querySelector('.mdl-js-layout'); + const drawer = document.querySelector('.mdl-layout__drawer'); + // hack, might get a built in alternative later + if (drawer.classList.contains('is-visible')) { + layout.MaterialLayout.toggleDrawer(); + } + }, 10); + } + } + + render() { + const { headerBackground } = this.props.uiConfig; + const style = headerBackground ? { background: headerBackground } : {}; + return ( + +
} style={style}> + + + +
+ +
+ ); + } +} + +export default connect( + state => ({ uiConfig: state.uiConfig.toJS() }), + { fetchUIConfig } +)(HeaderComponent); diff --git a/frontend/src/component/user/show-user-container.jsx b/frontend/src/component/user/show-user-container.jsx index acb836e5ec..b6c18817b9 100644 --- a/frontend/src/component/user/show-user-container.jsx +++ b/frontend/src/component/user/show-user-container.jsx @@ -13,4 +13,7 @@ const mapStateToProps = state => ({ location: state.settings ? state.settings.toJS().location : {}, }); -export default connect(mapStateToProps, mapDispatchToProps)(ShowUserComponent); +export default connect( + mapStateToProps, + mapDispatchToProps +)(ShowUserComponent); diff --git a/frontend/src/data/config-api.js b/frontend/src/data/config-api.js new file mode 100644 index 0000000000..2879e62361 --- /dev/null +++ b/frontend/src/data/config-api.js @@ -0,0 +1,13 @@ +import { throwIfNotSuccess } from './helper'; + +const URI = 'api/admin/ui-config'; + +function fetchConfig() { + return fetch(URI, { credentials: 'include' }) + .then(throwIfNotSuccess) + .then(response => response.json()); +} + +export default { + fetchConfig, +}; diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 33104de9b9..c6aaa4d955 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -1,7 +1,8 @@ import 'whatwg-fetch'; -import 'react-mdl/extra/material.css'; import 'react-mdl/extra/material.js'; +import 'react-mdl/extra/css/material.blue_grey-pink.min.css'; + import React from 'react'; import ReactDOM from 'react-dom'; import { HashRouter, Route } from 'react-router-dom'; diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index 278cea3ab9..7bb506be41 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -11,6 +11,7 @@ import settings from './settings'; import user from './user'; import api from './api'; import applications from './application'; +import uiConfig from './ui-config'; const unleashStore = combineReducers({ features, @@ -24,6 +25,7 @@ const unleashStore = combineReducers({ settings, user, applications, + uiConfig, api, }); diff --git a/frontend/src/store/ui-config/__tests__/__snapshots__/ui-config-store.test.js.snap b/frontend/src/store/ui-config/__tests__/__snapshots__/ui-config-store.test.js.snap new file mode 100644 index 0000000000..c62af75765 --- /dev/null +++ b/frontend/src/store/ui-config/__tests__/__snapshots__/ui-config-store.test.js.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should be default state 1`] = ` +Object { + "environment": undefined, + "headerBackground": undefined, + "slogan": "A product originally created by FINN.no.", +} +`; + +exports[`should be merged state all 1`] = ` +Object { + "environment": "dev", + "headerBackground": "red", + "slogan": "hello", +} +`; + +exports[`should only update headerBackground 1`] = ` +Object { + "environment": undefined, + "headerBackground": "black", + "slogan": "A product originally created by FINN.no.", +} +`; diff --git a/frontend/src/store/ui-config/__tests__/ui-config-store.test.js b/frontend/src/store/ui-config/__tests__/ui-config-store.test.js new file mode 100644 index 0000000000..3b3c6a2133 --- /dev/null +++ b/frontend/src/store/ui-config/__tests__/ui-config-store.test.js @@ -0,0 +1,29 @@ +import { AssertionError } from 'assert'; +import reducer from '../index'; +import { receiveConfig } from '../actions'; + +test('should be default state', () => { + const state = reducer(undefined, {}); + expect(state.toJS()).toMatchSnapshot(); +}); + +test('should be merged state all', () => { + const uiConfig = { + headerBackground: 'red', + slogan: 'hello', + environment: 'dev', + }; + + const state = reducer(undefined, receiveConfig(uiConfig)); + expect(state.toJS()).toMatchSnapshot(); +}); + +test('should only update headerBackground', () => { + localStorage.clear(); + const uiConfig = { + headerBackground: 'black', + }; + + const state = reducer(undefined, receiveConfig(uiConfig)); + expect(state.toJS()).toMatchSnapshot(); +}); diff --git a/frontend/src/store/ui-config/actions.js b/frontend/src/store/ui-config/actions.js new file mode 100644 index 0000000000..a7979d6ced --- /dev/null +++ b/frontend/src/store/ui-config/actions.js @@ -0,0 +1,18 @@ +import api from '../../data/config-api'; +import { dispatchAndThrow } from '../util'; + +export const RECEIVE_CONFIG = 'RECEIVE_CONFIG'; +export const ERROR_RECEIVE_CONFIG = 'ERROR_RECEIVE_CONFIG'; + +export const receiveConfig = json => ({ + type: RECEIVE_CONFIG, + value: json, +}); + +export function fetchUIConfig() { + return dispatch => + api + .fetchConfig() + .then(json => dispatch(receiveConfig(json))) + .catch(dispatchAndThrow(dispatch, ERROR_RECEIVE_CONFIG)); +} diff --git a/frontend/src/store/ui-config/index.js b/frontend/src/store/ui-config/index.js new file mode 100644 index 0000000000..59937e8be9 --- /dev/null +++ b/frontend/src/store/ui-config/index.js @@ -0,0 +1,41 @@ +import { Map as $Map } from 'immutable'; +import { RECEIVE_CONFIG } from './actions'; + +const localStorage = window.localStorage || { + setItem: () => {}, + getItem: () => {}, +}; + +const basePath = location ? location.pathname : '/'; +const UI_CONFIG = `${basePath}:ui_config`; + +const DEFAULT = new $Map({ + headerBackground: undefined, + environment: undefined, + slogan: 'A product originally created by FINN.no.', +}); + +function getInitState() { + try { + const state = JSON.parse(localStorage.getItem(UI_CONFIG)); + return state ? DEFAULT.merge(state) : DEFAULT; + } catch (e) { + return DEFAULT; + } +} + +function updateConfig(state, config) { + localStorage.setItem(UI_CONFIG, JSON.stringify(config)); + return state.merge(config); +} + +const strategies = (state = getInitState(), action) => { + switch (action.type) { + case RECEIVE_CONFIG: + return updateConfig(state, action.value); + default: + return state; + } +}; + +export default strategies;