1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-24 01:18:01 +02:00

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.
This commit is contained in:
ivaosthu 2019-03-11 21:10:45 +01:00
parent 4acc854a65
commit c4900262f2
20 changed files with 294 additions and 100 deletions

View File

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="unleash"> <meta name="description" content="unleash">
<title>Unleash UI</title> <title>Unleash - Enterprise ready feature toggles</title>
<link rel="stylesheet" href="public/bundle.css"> <link rel="stylesheet" href="public/bundle.css">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet"> <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet">

View File

@ -5,23 +5,13 @@ exports[`renders correctly with details 1`] = `
logo="Unleash 1.1.0" logo="Unleash 1.1.0"
type="bottom" type="bottom"
> >
<react-mdl-FooterLinkList> <small>
<a (test)
href="https://github.com/Unleash/unleash/" </small>
target="_blank" <br />
> <small>
GitHub We are the best!
</a> </small>
<a
href="https://www.finn.no"
target="_blank"
>
<small>
A product by
</small>
FINN.no
</a>
</react-mdl-FooterLinkList>
</react-mdl-FooterSection> </react-mdl-FooterSection>
`; `;
@ -30,22 +20,25 @@ exports[`renders correctly with empty api details 1`] = `
logo="Unleash " logo="Unleash "
type="bottom" type="bottom"
> >
<react-mdl-FooterLinkList> <small>
<a (test)
href="https://github.com/Unleash/unleash/" </small>
target="_blank" <br />
> <small>
GitHub We are the best!
</a> </small>
<a </react-mdl-FooterSection>
href="https://www.finn.no" `;
target="_blank"
> exports[`renders correctly without uiConfig 1`] = `
<small> <react-mdl-FooterSection
A product by logo="Unleash 1.1.0"
</small> type="bottom"
FINN.no >
</a> <small>
</react-mdl-FooterLinkList>
</small>
<br />
<small />
</react-mdl-FooterSection> </react-mdl-FooterSection>
`; `;

View File

@ -5,14 +5,28 @@ import renderer from 'react-test-renderer';
jest.mock('react-mdl'); jest.mock('react-mdl');
const uiConfig = {
slogan: 'We are the best!',
environment: 'test',
};
test('renders correctly with empty api details', () => { test('renders correctly with empty api details', () => {
const tree = renderer.create(<ShowApiDetailsComponent fetchAll={jest.fn()} apiDetails={{}} />).toJSON(); const tree = renderer
.create(<ShowApiDetailsComponent fetchAll={jest.fn()} apiDetails={{}} uiConfig={uiConfig} />)
.toJSON();
expect(tree).toMatchSnapshot(); expect(tree).toMatchSnapshot();
}); });
test('renders correctly with details', () => { test('renders correctly with details', () => {
const tree = renderer const tree = renderer
.create(<ShowApiDetailsComponent fetchAll={jest.fn()} apiDetails={{ version: '1.1.0' }} />) .create(<ShowApiDetailsComponent fetchAll={jest.fn()} apiDetails={{ version: '1.1.0' }} uiConfig={uiConfig} />)
.toJSON();
expect(tree).toMatchSnapshot();
});
test('renders correctly without uiConfig', () => {
const tree = renderer
.create(<ShowApiDetailsComponent fetchAll={jest.fn()} apiDetails={{ version: '1.1.0' }} uiConfig={{}} />)
.toJSON(); .toJSON();
expect(tree).toMatchSnapshot(); expect(tree).toMatchSnapshot();
}); });

View File

@ -1,10 +1,11 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { FooterSection, FooterLinkList } from 'react-mdl'; import { FooterSection } from 'react-mdl';
class ShowApiDetailsComponent extends Component { class ShowApiDetailsComponent extends Component {
static propTypes = { static propTypes = {
apiDetails: PropTypes.object.isRequired, apiDetails: PropTypes.object.isRequired,
uiConfig: PropTypes.object.isRequired,
fetchAll: PropTypes.func.isRequired, fetchAll: PropTypes.func.isRequired,
}; };
@ -14,16 +15,13 @@ class ShowApiDetailsComponent extends Component {
render() { render() {
const version = this.props.apiDetails.version || ''; const version = this.props.apiDetails.version || '';
const { slogan, environment } = this.props.uiConfig;
return ( return (
<FooterSection type="bottom" logo={`Unleash ${version}`}> <FooterSection type="bottom" logo={`Unleash ${version}`}>
<FooterLinkList> <small>{environment ? `(${environment})` : ''}</small>
<a href="https://github.com/Unleash/unleash/" target="_blank"> <br />
GitHub <small>{slogan}</small>
</a>
<a href="https://www.finn.no" target="_blank">
<small>A product by</small> FINN.no
</a>
</FooterLinkList>
</FooterSection> </FooterSection>
); );
} }

View File

@ -8,6 +8,10 @@ const mapDispatchToProps = {
const mapStateToProps = state => ({ const mapStateToProps = state => ({
apiDetails: state.api.toJS(), apiDetails: state.api.toJS(),
uiConfig: state.uiConfig.toJS(),
}); });
export default connect(mapStateToProps, mapDispatchToProps)(ShowApiDetailsComponent); export default connect(
mapStateToProps,
mapDispatchToProps
)(ShowApiDetailsComponent);

View File

@ -1,50 +1,30 @@
import React, { Component } from 'react'; import React, { PureComponent } from 'react';
import PropTypes from 'prop-types'; 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 { Route, Redirect, Switch } from 'react-router-dom';
import styles from './styles.scss'; import styles from './styles.scss';
import ErrorContainer from './error/error-container'; import ErrorContainer from './error/error-container';
import Header from './menu/header';
import AuthenticationContainer from './user/authentication-container'; import AuthenticationContainer from './user/authentication-container';
import ShowUserContainer from './user/show-user-container';
import ShowApiDetailsContainer from './api/show-api-details-container'; import ShowApiDetailsContainer from './api/show-api-details-container';
import Features from '../page/features'; import Features from '../page/features';
import { DrawerMenu } from './menu/drawer';
import { FooterMenu } from './menu/footer'; import { FooterMenu } from './menu/footer';
import Breadcrum from './menu/breadcrumb';
import { routes } from './menu/routes'; import { routes } from './menu/routes';
export default class App extends Component { export default class App extends PureComponent {
static propTypes = { static propTypes = {
location: PropTypes.object.isRequired, location: PropTypes.object.isRequired,
match: 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() { render() {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<AuthenticationContainer /> <AuthenticationContainer />
<Layout fixedHeader> <Layout fixedHeader>
<Header title={<Route path="/:path" component={Breadcrum} />}> <Header location={this.props.location} />
<Navigation>
<ShowUserContainer />
</Navigation>
</Header>
<DrawerMenu />
<Content className="mdl-color--grey-50"> <Content className="mdl-color--grey-50">
<Grid noSpacing className={styles.content}> <Grid noSpacing className={styles.content}>
<Cell col={12}> <Cell col={12}>

View File

@ -9,6 +9,9 @@ const mapStateToProps = state => {
}; };
}; };
const HistoryListContainer = connect(mapStateToProps, { fetchHistory })(HistoryComponent); const HistoryListContainer = connect(
mapStateToProps,
{ fetchHistory }
)(HistoryComponent);
export default HistoryListContainer; export default HistoryListContainer;

View File

@ -25,7 +25,7 @@ exports[`should render DrawerMenu 1`] = `
> >
<a <a
aria-current={null} aria-current={null}
className="navigationLink mdl-color-text--grey-600" className="navigationLink mdl-color-text--grey-900"
href="/features" href="/features"
onClick={[Function]} onClick={[Function]}
> >
@ -38,7 +38,7 @@ exports[`should render DrawerMenu 1`] = `
</a> </a>
<a <a
aria-current={null} aria-current={null}
className="navigationLink mdl-color-text--grey-600" className="navigationLink mdl-color-text--grey-900"
href="/strategies" href="/strategies"
onClick={[Function]} onClick={[Function]}
> >
@ -51,7 +51,7 @@ exports[`should render DrawerMenu 1`] = `
</a> </a>
<a <a
aria-current={null} aria-current={null}
className="navigationLink mdl-color-text--grey-600" className="navigationLink mdl-color-text--grey-900"
href="/history" href="/history"
onClick={[Function]} onClick={[Function]}
> >
@ -64,7 +64,7 @@ exports[`should render DrawerMenu 1`] = `
</a> </a>
<a <a
aria-current={null} aria-current={null}
className="navigationLink mdl-color-text--grey-600" className="navigationLink mdl-color-text--grey-900"
href="/archive" href="/archive"
onClick={[Function]} onClick={[Function]}
> >
@ -77,7 +77,7 @@ exports[`should render DrawerMenu 1`] = `
</a> </a>
<a <a
aria-current={null} aria-current={null}
className="navigationLink mdl-color-text--grey-600" className="navigationLink mdl-color-text--grey-900"
href="/applications" href="/applications"
onClick={[Function]} onClick={[Function]}
> >
@ -90,7 +90,7 @@ exports[`should render DrawerMenu 1`] = `
</a> </a>
<a <a
aria-current={null} aria-current={null}
className="navigationLink mdl-color-text--grey-600" className="navigationLink mdl-color-text--grey-900"
href="/logout" href="/logout"
onClick={[Function]} onClick={[Function]}
> >
@ -107,7 +107,7 @@ exports[`should render DrawerMenu 1`] = `
className="navigation" className="navigation"
> >
<a <a
className="navigationLink mdl-color-text--grey-600" className="navigationLink mdl-color-text--grey-900"
href="https://unleash.github.io" href="https://unleash.github.io"
target="_blank" target="_blank"
> >
@ -118,14 +118,14 @@ exports[`should render DrawerMenu 1`] = `
User documentation User documentation
</a> </a>
<a <a
className="navigationLink mdl-color-text--grey-600" className="navigationLink mdl-color-text--grey-900"
href="https://github.com/Unleash" href="https://github.com/Unleash"
target="_blank" target="_blank"
> >
<i <i
className="material-icons navigationIcon iconGitHub" className="material-icons navigationIcon iconGitHub"
/> />
GitHub GitHub
</a> </a>
</react-mdl-Navigation> </react-mdl-Navigation>
</react-mdl-Drawer> </react-mdl-Drawer>
@ -156,7 +156,7 @@ exports[`should render DrawerMenu with "features" selected 1`] = `
> >
<a <a
aria-current="page" aria-current="page"
className="navigationLink mdl-color-text--grey-600 navigationLink mdl-color-text--black mdl-color--light-blue-50" className="navigationLink mdl-color-text--grey-900 navigationLink mdl-color-text--black mdl-color--blue-grey-100"
href="/features" href="/features"
onClick={[Function]} onClick={[Function]}
style={Object {}} style={Object {}}
@ -170,7 +170,7 @@ exports[`should render DrawerMenu with "features" selected 1`] = `
</a> </a>
<a <a
aria-current={null} aria-current={null}
className="navigationLink mdl-color-text--grey-600" className="navigationLink mdl-color-text--grey-900"
href="/strategies" href="/strategies"
onClick={[Function]} onClick={[Function]}
> >
@ -183,7 +183,7 @@ exports[`should render DrawerMenu with "features" selected 1`] = `
</a> </a>
<a <a
aria-current={null} aria-current={null}
className="navigationLink mdl-color-text--grey-600" className="navigationLink mdl-color-text--grey-900"
href="/history" href="/history"
onClick={[Function]} onClick={[Function]}
> >
@ -196,7 +196,7 @@ exports[`should render DrawerMenu with "features" selected 1`] = `
</a> </a>
<a <a
aria-current={null} aria-current={null}
className="navigationLink mdl-color-text--grey-600" className="navigationLink mdl-color-text--grey-900"
href="/archive" href="/archive"
onClick={[Function]} onClick={[Function]}
> >
@ -209,7 +209,7 @@ exports[`should render DrawerMenu with "features" selected 1`] = `
</a> </a>
<a <a
aria-current={null} aria-current={null}
className="navigationLink mdl-color-text--grey-600" className="navigationLink mdl-color-text--grey-900"
href="/applications" href="/applications"
onClick={[Function]} onClick={[Function]}
> >
@ -222,7 +222,7 @@ exports[`should render DrawerMenu with "features" selected 1`] = `
</a> </a>
<a <a
aria-current={null} aria-current={null}
className="navigationLink mdl-color-text--grey-600" className="navigationLink mdl-color-text--grey-900"
href="/logout" href="/logout"
onClick={[Function]} onClick={[Function]}
> >
@ -239,7 +239,7 @@ exports[`should render DrawerMenu with "features" selected 1`] = `
className="navigation" className="navigation"
> >
<a <a
className="navigationLink mdl-color-text--grey-600" className="navigationLink mdl-color-text--grey-900"
href="https://unleash.github.io" href="https://unleash.github.io"
target="_blank" target="_blank"
> >
@ -250,14 +250,14 @@ exports[`should render DrawerMenu with "features" selected 1`] = `
User documentation User documentation
</a> </a>
<a <a
className="navigationLink mdl-color-text--grey-600" className="navigationLink mdl-color-text--grey-900"
href="https://github.com/Unleash" href="https://github.com/Unleash"
target="_blank" target="_blank"
> >
<i <i
className="material-icons navigationIcon iconGitHub" className="material-icons navigationIcon iconGitHub"
/> />
GitHub GitHub
</a> </a>
</react-mdl-Navigation> </react-mdl-Navigation>
</react-mdl-Drawer> </react-mdl-Drawer>

View File

@ -50,6 +50,12 @@ exports[`should render DrawerMenu 1`] = `
> >
Sign out Sign out
</a> </a>
<a
href="https://github.com/Unleash/unleash/"
target="_blank"
>
GitHub
</a>
</react-mdl-FooterLinkList> </react-mdl-FooterLinkList>
</react-mdl-FooterDropDownSection> </react-mdl-FooterDropDownSection>
<react-mdl-FooterDropDownSection <react-mdl-FooterDropDownSection
@ -138,6 +144,12 @@ exports[`should render DrawerMenu with "features" selected 1`] = `
> >
Sign out Sign out
</a> </a>
<a
href="https://github.com/Unleash/unleash/"
target="_blank"
>
GitHub
</a>
</react-mdl-FooterLinkList> </react-mdl-FooterLinkList>
</react-mdl-FooterDropDownSection> </react-mdl-FooterDropDownSection>
<react-mdl-FooterDropDownSection <react-mdl-FooterDropDownSection

View File

@ -17,8 +17,8 @@ export const DrawerMenu = () => (
<NavLink <NavLink
key={item.path} key={item.path}
to={item.path} to={item.path}
className={[styles.navigationLink, 'mdl-color-text--grey-600'].join(' ')} className={[styles.navigationLink, 'mdl-color-text--grey-900'].join(' ')}
activeClassName={[styles.navigationLink, 'mdl-color-text--black', 'mdl-color--light-blue-50'].join( activeClassName={[styles.navigationLink, 'mdl-color-text--black', 'mdl-color--blue-grey-100'].join(
' ' ' '
)} )}
> >
@ -31,16 +31,16 @@ export const DrawerMenu = () => (
<a <a
href="https://unleash.github.io" href="https://unleash.github.io"
target="_blank" target="_blank"
className={[styles.navigationLink, 'mdl-color-text--grey-600'].join(' ')} className={[styles.navigationLink, 'mdl-color-text--grey-900'].join(' ')}
> >
<Icon name="library_books" className={styles.navigationIcon} /> User documentation <Icon name="library_books" className={styles.navigationIcon} /> User documentation
</a> </a>
<a <a
href="https://github.com/Unleash" href="https://github.com/Unleash"
target="_blank" target="_blank"
className={[styles.navigationLink, 'mdl-color-text--grey-600'].join(' ')} className={[styles.navigationLink, 'mdl-color-text--grey-900'].join(' ')}
> >
<i className={['material-icons', styles.navigationIcon, styles.iconGitHub].join(' ')} />GitHub <i className={['material-icons', styles.navigationIcon, styles.iconGitHub].join(' ')} /> GitHub
</a> </a>
</Navigation> </Navigation>
</Drawer> </Drawer>

View File

@ -13,6 +13,9 @@ export const FooterMenu = () => (
{item.title} {item.title}
</NavLink> </NavLink>
))} ))}
<a href="https://github.com/Unleash/unleash/" target="_blank">
GitHub
</a>
</FooterLinkList> </FooterLinkList>
</FooterDropDownSection> </FooterDropDownSection>
<FooterDropDownSection title="Clients"> <FooterDropDownSection title="Clients">

View File

@ -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 (
<React.Fragment>
<Header title={<Route path="/:path" component={Breadcrum} />} style={style}>
<Navigation>
<ShowUserContainer />
</Navigation>
</Header>
<DrawerMenu />
</React.Fragment>
);
}
}
export default connect(
state => ({ uiConfig: state.uiConfig.toJS() }),
{ fetchUIConfig }
)(HeaderComponent);

View File

@ -13,4 +13,7 @@ const mapStateToProps = state => ({
location: state.settings ? state.settings.toJS().location : {}, location: state.settings ? state.settings.toJS().location : {},
}); });
export default connect(mapStateToProps, mapDispatchToProps)(ShowUserComponent); export default connect(
mapStateToProps,
mapDispatchToProps
)(ShowUserComponent);

View File

@ -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,
};

View File

@ -1,7 +1,8 @@
import 'whatwg-fetch'; import 'whatwg-fetch';
import 'react-mdl/extra/material.css';
import 'react-mdl/extra/material.js'; import 'react-mdl/extra/material.js';
import 'react-mdl/extra/css/material.blue_grey-pink.min.css';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { HashRouter, Route } from 'react-router-dom'; import { HashRouter, Route } from 'react-router-dom';

View File

@ -11,6 +11,7 @@ import settings from './settings';
import user from './user'; import user from './user';
import api from './api'; import api from './api';
import applications from './application'; import applications from './application';
import uiConfig from './ui-config';
const unleashStore = combineReducers({ const unleashStore = combineReducers({
features, features,
@ -24,6 +25,7 @@ const unleashStore = combineReducers({
settings, settings,
user, user,
applications, applications,
uiConfig,
api, api,
}); });

View File

@ -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.",
}
`;

View File

@ -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();
});

View File

@ -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));
}

View File

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