diff --git a/frontend/.travis.yml b/frontend/.travis.yml index 1f38c41166..27269a4397 100644 --- a/frontend/.travis.yml +++ b/frontend/.travis.yml @@ -6,6 +6,8 @@ before_install: yarn global add greenkeeper-lockfile@1 before_script: greenkeeper-lockfile-update script: yarn run test:ci after_script: greenkeeper-lockfile-upload +cache: + yarn: true env: global: secure: F5bMAyZEYW7LBwV+L3sdW+jhA+4H9dKte70GlhtPQdjGuFM0bWb4DIyECOUj0HIVjLKq8lDx4jolbNDvjccecteGEgZEcHKCE/WRpOlB3R8WhQdsIk3j+FaMRQNI2aH6hmRVUysEf6RuTIcYcESIExP1eGcy79++Zon953uM26k6NYKvWjURkn8fLM/Bj6RXKZNBmzdCsvHNPSF6R+VC0rU3NdDBD+r89vA7cz9zkXCe5LYnVzcnzSrsBTExpZrc0PVDPxNBZrJdDSe9KsEWEO+Ag2kZAEmv2xedOCaFCO6PiniZgCBKkw2zQM4ittaP0NnK+QBtYOffhuxoGxqT953sz6UTtDJiYWy+68N57zECQr4TFTAL2F8Cjh2Z/KYO6jxlKGL/1kL2UO/1ovfjJeFfl+2tm/F1bYj7dYA+swS/72cDSH0nrrEuv6mAA5hWoLD3m55bqg9kwjYPO8skzlLNLJ2Q0k0kpd3c3zveNOF9cKZ87pFbShi5sWj3vWm8FMZudjday6MBjghGKGICI8NpQZFnETrthXWqUd5+PfxIqbEov5jZQAS0FrHc2wfIneRmBoP64ST9A5SA+9lA81qK7iw9eDi/e2/C7CsbgWtL4CTvk37HerCPTv2hDxZuUxgJ3p5QoOkdU+TWSvnY7Z+HnniRG0nMh90XLwQiBQU= diff --git a/frontend/CHANGELOG.md b/frontend/CHANGELOG.md index aaa51f894c..f667936c61 100644 --- a/frontend/CHANGELOG.md +++ b/frontend/CHANGELOG.md @@ -9,6 +9,7 @@ The latest version of this document is always available in ## [next] - fix: Use toggle/on/off endoints to ensure correct state +- feat: Customisable UI via config ## [3.2.1] - fix: Fixed bug in history view preventing toggle-view. 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..fbd71fc284 100644 --- a/frontend/src/component/api/show-api-details-container.jsx +++ b/frontend/src/component/api/show-api-details-container.jsx @@ -8,6 +8,7 @@ const mapDispatchToProps = { const mapStateToProps = state => ({ apiDetails: state.api.toJS(), + uiConfig: state.uiConfig.toJS(), }); 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/common/index.js b/frontend/src/component/common/index.js index 6d21dd885c..4d839b4a8d 100644 --- a/frontend/src/component/common/index.js +++ b/frontend/src/component/common/index.js @@ -69,12 +69,14 @@ DataTableHeader.propTypes = { export const FormButtons = ({ submitText = 'Create', onCancel }) => (
 
); diff --git a/frontend/src/component/feature/form/form-view-feature-component.jsx b/frontend/src/component/feature/form/form-view-feature-component.jsx index 3fa862c5a6..7cc3ebdf64 100644 --- a/frontend/src/component/feature/form/form-view-feature-component.jsx +++ b/frontend/src/component/feature/form/form-view-feature-component.jsx @@ -13,7 +13,8 @@ class ViewFeatureComponent extends Component {
); diff --git a/frontend/src/component/feature/form/strategy-configure.jsx b/frontend/src/component/feature/form/strategy-configure.jsx index a15e217a61..0bd5397ec2 100644 --- a/frontend/src/component/feature/form/strategy-configure.jsx +++ b/frontend/src/component/feature/form/strategy-configure.jsx @@ -162,7 +162,9 @@ class StrategyConfigure extends React.Component { item = ( -  {name} + +   + {name} {this.props.strategyDefinition.description} {inputFields && ( diff --git a/frontend/src/component/feature/variant/update-variant-component.jsx b/frontend/src/component/feature/variant/update-variant-component.jsx index cc9ed390bd..dc0e290c94 100644 --- a/frontend/src/component/feature/variant/update-variant-component.jsx +++ b/frontend/src/component/feature/variant/update-variant-component.jsx @@ -106,7 +106,8 @@ class UpdateVariantComponent extends Component { supports variants. You should read more about variants in the  user documentation - . + + .

The sum of variants weights needs to be a constant number to guarantee consistent hashing in the 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..7685ac9b8d --- /dev/null +++ b/frontend/src/component/menu/header.jsx @@ -0,0 +1,52 @@ +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-component.jsx b/frontend/src/component/user/show-user-component.jsx index 140f7af47d..e84b3d85ac 100644 --- a/frontend/src/component/user/show-user-component.jsx +++ b/frontend/src/component/user/show-user-component.jsx @@ -46,7 +46,8 @@ export default class ShowUserComponent extends React.Component {
{locale} -
  +
+  
{email}
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..cf765a1616 --- /dev/null +++ b/frontend/src/store/ui-config/__tests__/ui-config-store.test.js @@ -0,0 +1,27 @@ +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', () => { + 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;