diff --git a/frontend/.babelrc b/frontend/.babelrc new file mode 100644 index 0000000000..4d3b7d3676 --- /dev/null +++ b/frontend/.babelrc @@ -0,0 +1,8 @@ +{ + "presets": ["react", "es2015", "stage-2"], + "env": { + "development": { + "presets": ["react-hmre"] + } + } +} diff --git a/frontend/.eslintignore b/frontend/.eslintignore new file mode 100644 index 0000000000..d1c1dbe227 --- /dev/null +++ b/frontend/.eslintignore @@ -0,0 +1,2 @@ +node_modules +bundle.js diff --git a/frontend/.eslintrc b/frontend/.eslintrc new file mode 100644 index 0000000000..ee0b68514f --- /dev/null +++ b/frontend/.eslintrc @@ -0,0 +1,9 @@ +{ + "extends": [ + "finn", + "finn/node" + ], + "rules": { + "no-shadow": 0 + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore index 5148e527a7..e2af53d0a9 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -35,3 +35,6 @@ jspm_packages # Optional REPL history .node_repl_history + +# bundled assets +dist diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000000..9e0a26fa5b --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,14 @@ +## Start developing: + +1. start mock-api: + +```bash +npm run start:api +``` + +2. start webpack-dev-server with hot-reload: +```bash +npm run start +``` + +Happy coding! diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000..7c0bb5e22d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + Unleash Admin + + + + + +
+ + + diff --git a/frontend/index.js b/frontend/index.js new file mode 100644 index 0000000000..621880f22a --- /dev/null +++ b/frontend/index.js @@ -0,0 +1,7 @@ +'use strict'; + +const path = require('path'); + +module.exports = { + publicFolder: path.join(__dirname, 'dist'), +}; diff --git a/frontend/jest-preprocessor.js b/frontend/jest-preprocessor.js new file mode 100644 index 0000000000..66c8d10d00 --- /dev/null +++ b/frontend/jest-preprocessor.js @@ -0,0 +1,9 @@ +// preprocessor.js +'use strict'; + +const ReactTools = require('react-tools'); +module.exports = { + process (src) { + return ReactTools.transform(src); + }, +}; diff --git a/frontend/mock-api.json b/frontend/mock-api.json new file mode 100644 index 0000000000..0bc4ee1726 --- /dev/null +++ b/frontend/mock-api.json @@ -0,0 +1,74 @@ +{ + "features": { + "version": 1, + "features": [ + { + "name": "Feature.A", + "description": "lorem ipsum", + "enabled": false, + "strategies": [ + { + "name": "default", + "parameters": {} + } + ] + }, + { + "name": "Feature.B", + "description": "lorem ipsum", + "enabled": true, + "strategies": [ + { + "name": "ActiveForUserWithId", + "parameters": { + "userIdList": "123,221,998" + } + }, + { + "name": "GradualRolloutRandom", + "parameters": { + "percentage": "10" + } + } + ] + }, + { + "name": "Feature.C", + "description": "lorem ipsum", + "enabled": false, + "strategies": [ + { + "name": "default", + "parameters": {} + } + ] + }, + { + "name": "Feature.D", + "description": "lorem ipsum", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {} + } + ] + }, + { + "name": "Feature.E", + "description": "lorem ipsum", + "enabled": true, + "strategies": [ + { + "name": "default", + "parameters": {} + }, + { + "name": "FancyStrat", + "parameters": {} + } + ] + } + ] + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000..eb6e15f538 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,89 @@ +{ + "name": "unleash-frontend-next", + "description": "unleash your features", + "version": "1.0.0", + "keywords": [ + "unleash", + "feature toggle", + "feature", + "toggle" + ], + "files": [ + "public" + ], + "repository": { + "type": "git", + "url": "ssh://git@github.com:finn-no/unleash.git" + }, + "bugs": { + "url": "https://github.com/finn-no/unleash/issues" + }, + "engines": { + "node": "6" + }, + "scripts": { + "build": "webpack -p", + "start": "webpack-dev-server --config webpack.config.js --hot --progress --colors --port 3000", + "start:api": "json-server --watch mock-api.json -p 3001", + "lint": "eslint . --ext=js,jsx", + "test": "echo 'no test'", + "test:ci": "npm run test", + "prepublish": "npm run build" + }, + "main": "./index.js", + "dependencies": { + "debug": "^2.2.0", + "immutability-helper": "^2.0.0", + "immutable": "^3.8.1", + "normalize.css": "^4.2.0", + "react": "^15.3.1", + "react-addons-css-transition-group": "^15.3.1", + "react-dom": "^15.3.1", + "react-redux": "^4.4.5", + "react-router": "^2.8.0", + "react-toolbox": "^1.2.1", + "redux": "^3.6.0", + "redux-thunk": "^2.1.0" + }, + "devDependencies": { + "babel-core": "^6.14.0", + "babel-loader": "^6.2.5", + "babel-preset-es2015": "^6.14.0", + "babel-preset-react": "^6.11.1", + "babel-preset-react-hmre": "^1.1.1", + "babel-preset-stage-0": "^6.5.0", + "babel-preset-stage-2": "^6.13.0", + "css-loader": "^0.25.0", + "eslint": "^3.4.0", + "eslint-config-finn": "1.0.0-alpha.11", + "eslint-config-finn-react": "^1.0.0-alpha.2", + "eslint-plugin-react": "^6.2.0", + "extract-text-webpack-plugin": "^1.0.1", + "json-server": "^0.8.21", + "node-sass": "~3.7.0", + "postcss-loader": "^0.13.0", + "redux-devtools": "^3.3.1", + "sass-loader": "^4.0.2", + "style-loader": "^0.13.1", + "toolbox-loader": "0.0.3", + "webpack": "^1.13.2", + "webpack-dev-server": "^1.15.1" + }, + "jest": { + "scriptPreprocessor": "/jest-preprocessor.js", + "modulePathIgnorePatterns": [ + "/node_modules/npm" + ], + "unmockedModulePathPatterns": [ + "/node_modules/react", + "/node_modules/reflux" + ], + "moduleFileExtensions": [ + "jsx", + "js" + ] + }, + "pre-commit": [ + "lint" + ] +} diff --git a/frontend/src/.eslintrc b/frontend/src/.eslintrc new file mode 100644 index 0000000000..67c2bde374 --- /dev/null +++ b/frontend/src/.eslintrc @@ -0,0 +1,25 @@ +{ + "parser": "babel-eslint", + "extends": [ + "finn", + "finn-react", + "finn/es-modules" + ], + "env": { + "browser": true, + "commonjs": true, + "es6": true + }, + "parserOptions": { + "ecmaVersion": 7, + "ecmaFeatures": { + "experimentalObjectRestSpread": true, + "classes":true, + "spread":true, + "restParams": true + } + }, + "rules": { + "no-shadow": 0 + } +} diff --git a/frontend/src/component/app.jsx b/frontend/src/component/app.jsx new file mode 100644 index 0000000000..34f65038f4 --- /dev/null +++ b/frontend/src/component/app.jsx @@ -0,0 +1,40 @@ +import React, { Component } from 'react'; +import { Layout, Panel, NavDrawer, AppBar } from 'react-toolbox'; +import style from './styles.scss'; +import ErrorContainer from './error/error-container'; + +import Navigation from './nav'; + +export default class App extends Component { + constructor (props) { + super(props); + this.state = { drawerActive: false }; + + this.toggleDrawerActive = () => { + this.setState({ drawerActive: !this.state.drawerActive }); + }; + } + + onOverlayClick = () => this.setState({ drawerActive: false }); + + render () { + return ( +
+ +
+ + + + + +
+ {this.props.children} +
+
+ +
+
+
+ ); + } +}; diff --git a/frontend/src/component/archive/archive-container.js b/frontend/src/component/archive/archive-container.js new file mode 100644 index 0000000000..a7b589e5d7 --- /dev/null +++ b/frontend/src/component/archive/archive-container.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import ListComponent from './archive-list-component'; +import { fetchArchive, revive } from '../../store/archive-actions'; + +const mapStateToProps = (state) => { + const archive = state.archive.get('list').toArray(); + + return { + archive, + }; +}; + +const ArchiveListContainer = connect(mapStateToProps, { fetchArchive, revive })(ListComponent); + +export default ArchiveListContainer; diff --git a/frontend/src/component/archive/archive-list-component.jsx b/frontend/src/component/archive/archive-list-component.jsx new file mode 100644 index 0000000000..5dcd783ba5 --- /dev/null +++ b/frontend/src/component/archive/archive-list-component.jsx @@ -0,0 +1,48 @@ +import React, { Component } from 'react'; +import { List, ListItem, ListSubHeader } from 'react-toolbox/lib/list'; +import FontIcon from 'react-toolbox/lib/font_icon'; +import Chip from 'react-toolbox/lib/chip'; +import Switch from 'react-toolbox/lib/switch'; + +const ArchivedFeature = ({ feature, revive }) => { + const { name, description, enabled, strategies } = feature; + const actions = [ +
{strategies && strategies.map(s => {s.name})}
, + revive(feature)} />, + ]; + + const leftActions = [ + , + ]; + + return ( + + ); +}; + +class ArchiveList extends Component { + componentDidMount () { + this.props.fetchArchive(); + } + + render () { + const { archive, revive } = this.props; + return ( + + + {archive.length > 0 ? + archive.map((feature, i) => ) : + } + + ); + } +} + + +export default ArchiveList; diff --git a/frontend/src/component/client-instance/client-instance-component.js b/frontend/src/component/client-instance/client-instance-component.js new file mode 100644 index 0000000000..6d22b39433 --- /dev/null +++ b/frontend/src/component/client-instance/client-instance-component.js @@ -0,0 +1,38 @@ +import React, { Component, PropTypes } from 'react'; +import Table from 'react-toolbox/lib/table'; + +const Model = { + appName: { type: String, title: 'Application Name' }, + instanceId: { type: String }, + clientIp: { type: String }, + createdAt: { type: String }, + lastSeen: { type: String }, +}; + +class ClientStrategies extends Component { + static propTypes () { + return { + fetchClientInstances: PropTypes.func.isRequired, + clientInstances: PropTypes.array.isRequired, + }; + } + + componentDidMount () { + this.props.fetchClientInstances(); + } + + render () { + const source = this.props.clientInstances; + + return ( + + ); + } +} + + +export default ClientStrategies; diff --git a/frontend/src/component/client-instance/client-instance-container.js b/frontend/src/component/client-instance/client-instance-container.js new file mode 100644 index 0000000000..4c13df8eab --- /dev/null +++ b/frontend/src/component/client-instance/client-instance-container.js @@ -0,0 +1,9 @@ +import { connect } from 'react-redux'; +import ClientInstances from './client-instance-component'; +import { fetchClientInstances } from '../../store/client-instance-actions'; + +const mapStateToProps = (state) => ({ clientInstances: state.clientInstances.toJS() }); + +const StrategiesContainer = connect(mapStateToProps, { fetchClientInstances })(ClientInstances); + +export default StrategiesContainer; diff --git a/frontend/src/component/client-strategy/strategy-component.js b/frontend/src/component/client-strategy/strategy-component.js new file mode 100644 index 0000000000..3814f84dcb --- /dev/null +++ b/frontend/src/component/client-strategy/strategy-component.js @@ -0,0 +1,34 @@ +import React, { Component } from 'react'; +import Table from 'react-toolbox/lib/table'; + +const Model = { + appName: { type: String, title: 'Application Name' }, + strategies: { type: String }, +}; + +class ClientStrategies extends Component { + + componentDidMount () { + this.props.fetchClientStrategies(); + } + + render () { + const source = this.props.clientStrategies.map(item => ( + { + appName: item.appName, + strategies: item.strategies.join(', '), + }) + ); + + return ( +
+ ); + } +} + + +export default ClientStrategies; diff --git a/frontend/src/component/client-strategy/strategy-container.js b/frontend/src/component/client-strategy/strategy-container.js new file mode 100644 index 0000000000..e227c65a8a --- /dev/null +++ b/frontend/src/component/client-strategy/strategy-container.js @@ -0,0 +1,9 @@ +import { connect } from 'react-redux'; +import ClientStrategies from './strategy-component'; +import { fetchClientStrategies } from '../../store/client-strategy-actions'; + +const mapStateToProps = (state) => ({ clientStrategies: state.clientStrategies.toJS() }); + +const StrategiesContainer = connect(mapStateToProps, { fetchClientStrategies })(ClientStrategies); + +export default StrategiesContainer; diff --git a/frontend/src/component/error/error-component.jsx b/frontend/src/component/error/error-component.jsx new file mode 100644 index 0000000000..22e3d6faf2 --- /dev/null +++ b/frontend/src/component/error/error-component.jsx @@ -0,0 +1,28 @@ +import Snackbar from 'react-toolbox/lib/snackbar'; +import React, { PropTypes } from 'react'; + +class ErrorComponent extends React.Component { + static propTypes () { + return { + errors: PropTypes.array.isRequired, + muteError: PropTypes.func.isRequired, + }; + } + + render () { + const showError = this.props.errors.length > 0; + const error = showError ? this.props.errors[0] : undefined; + return ( + this.props.muteError(error)} + type="warning" + /> + ); + } +} + +export default ErrorComponent; diff --git a/frontend/src/component/error/error-container.jsx b/frontend/src/component/error/error-container.jsx new file mode 100644 index 0000000000..04dd974fd0 --- /dev/null +++ b/frontend/src/component/error/error-container.jsx @@ -0,0 +1,14 @@ +import { connect } from 'react-redux'; +import ErrorComponent from './error-component'; +import { muteError } from '../../store/error-actions'; + + +const mapDispatchToProps = { + muteError, +}; + +const mapStateToProps = (state) => ({ + errors: state.error.get('list').toArray(), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ErrorComponent); diff --git a/frontend/src/component/feature/feature-component.jsx b/frontend/src/component/feature/feature-component.jsx new file mode 100644 index 0000000000..84920fcad5 --- /dev/null +++ b/frontend/src/component/feature/feature-component.jsx @@ -0,0 +1,53 @@ +import React, { PropTypes } from 'react'; + +import { Link } from 'react-router'; +import FontIcon from 'react-toolbox/lib/font_icon'; +import Switch from 'react-toolbox/lib/switch'; +import { ListItem } from 'react-toolbox/lib/list'; +import Chip from 'react-toolbox/lib/chip'; + +import style from './feature.scss'; + +const Feature = ({ + feature, + onFeatureClick, + onFeatureRemove, + metricsLastHour = { yes: 0, no: 0, hasData: false }, + metricsLastMinute = { yes: 0, no: 0, hasData: false }, +}) => { + const { name, description, enabled, strategies, createdAt } = feature; + const created = new Date(createdAt); + + const actions = [ +
{strategies && strategies.map((s, i) => {s.name})}
, +
({created.toLocaleDateString('nb-NO')})
, + + + , + onFeatureRemove(name)} />, + ]; + + const leftActions = [ + {metricsLastHour.yes} / {metricsLastHour.no}, + {metricsLastMinute.yes} / {metricsLastMinute.no}, + onFeatureClick(feature)} checked={enabled} />, + ]; + + return ( + + ); +}; + +Feature.propTypes = { + feature: PropTypes.object, + onFeatureClick: PropTypes.func, + onFeatureRemove: PropTypes.func, +}; + +export default Feature; diff --git a/frontend/src/component/feature/feature.scss b/frontend/src/component/feature/feature.scss new file mode 100644 index 0000000000..636272d55f --- /dev/null +++ b/frontend/src/component/feature/feature.scss @@ -0,0 +1,16 @@ +.link { + color: #212121; +} + +.action { + color: #aaa !important; + cursor: pointer; +} + +.yes { + color: green; +} + +.no { + color: red; +} diff --git a/frontend/src/component/feature/form-add-container.jsx b/frontend/src/component/feature/form-add-container.jsx new file mode 100644 index 0000000000..8a2ab56105 --- /dev/null +++ b/frontend/src/component/feature/form-add-container.jsx @@ -0,0 +1,47 @@ +import { connect } from 'react-redux'; +import { hashHistory } from 'react-router'; +import { createFeatureToggles, validateName } from '../../store/feature-actions'; +import { createMapper, createActions } from '../input-helpers'; +import FormComponent from './form'; + +const ID = 'add-feature-toggle'; +const mapStateToProps = createMapper({ id: ID }); +const prepare = (methods, dispatch) => { + methods.onSubmit = (input) => ( + (e) => { + e.preventDefault(); + createFeatureToggles(input)(dispatch) + .then(() => methods.clear()) + .then(() => hashHistory.push('/features')); + } + ); + + methods.onCancel = (evt) => { + evt.preventDefault(); + hashHistory.push('/features'); + }; + + methods.addStrategy = (v) => { + methods.pushToList('strategies', v); + }; + + methods.updateStrategy = (v, n) => { + methods.updateInList('strategies', v, n); + }; + + methods.removeStrategy = (v) => { + methods.removeFromList('strategies', v); + }; + + methods.validateName = (v) => { + const featureToggleName = v.target.value; + validateName(featureToggleName) + .then(() => methods.setValue('nameError', undefined)) + .catch((err) => methods.setValue('nameError', err.message)); + }; + + return methods; +}; +const actions = createActions({ id: ID, prepare }); + +export default connect(mapStateToProps, actions)(FormComponent); diff --git a/frontend/src/component/feature/form-edit-container.jsx b/frontend/src/component/feature/form-edit-container.jsx new file mode 100644 index 0000000000..b9264f7b82 --- /dev/null +++ b/frontend/src/component/feature/form-edit-container.jsx @@ -0,0 +1,70 @@ +import { connect } from 'react-redux'; +import { hashHistory } from 'react-router'; + +import { requestUpdateFeatureToggle } from '../../store/feature-actions'; +import { createMapper, createActions } from '../input-helpers'; +import FormComponent from './form'; + +const ID = 'edit-feature-toggle'; +function getId (props) { + return [ID, props.featureToggleName]; +} +// TODO: need to scope to the active featureToggle +// best is to emulate the "input-storage"? +const mapStateToProps = createMapper({ + id: getId, + getDefault: (state, ownProps) => { + if (ownProps.featureToggleName) { + const match = state.features.findEntry((entry) => entry.get('name') === ownProps.featureToggleName); + + if (match && match[1]) { + return match[1].toJS(); + } + } + return {}; + }, + prepare: (props) => { + props.editmode = true; + return props; + }, +}); + +const prepare = (methods, dispatch) => { + methods.onSubmit = (input) => ( + (e) => { + e.preventDefault(); + // TODO: should add error handling + requestUpdateFeatureToggle(input)(dispatch) + .then(() => methods.clear()) + .then(() => window.history.back()); + } + ); + + methods.onCancel = (evt) => { + evt.preventDefault(); + hashHistory.push('/features'); + }; + + methods.addStrategy = (v) => { + methods.pushToList('strategies', v); + }; + + methods.removeStrategy = (v) => { + methods.removeFromList('strategies', v); + }; + + methods.updateStrategy = (v, n) => { + methods.updateInList('strategies', v, n); + }; + + methods.validateName = () => {}; + + return methods; +}; + +const actions = createActions({ + id: getId, + prepare, +}); + +export default connect(mapStateToProps, actions)(FormComponent); diff --git a/frontend/src/component/feature/form/index.jsx b/frontend/src/component/feature/form/index.jsx new file mode 100644 index 0000000000..f292085a01 --- /dev/null +++ b/frontend/src/component/feature/form/index.jsx @@ -0,0 +1,95 @@ +import React, { Component, PropTypes } from 'react'; +import Input from 'react-toolbox/lib/input'; +import Button from 'react-toolbox/lib/button'; +import Switch from 'react-toolbox/lib/switch'; +import StrategiesSection from './strategies-section-container'; + +class AddFeatureToggleComponent extends Component { + + componentWillMount () { + // TODO unwind this stuff + if (this.props.initCallRequired === true) { + this.props.init(this.props.input); + } + } + + render () { + const { + input, + setValue, + validateName, + addStrategy, + removeStrategy, + updateStrategy, + onSubmit, + onCancel, + editmode = false, + } = this.props; + + const { + name, // eslint-disable-line + nameError, + description, + enabled, + } = input; + const configuredStrategies = input.strategies || []; + + return ( +
+
+ validateName(v)} + onChange={(v) => setValue('name', v)} /> + setValue('description', v)} /> + +
+ + setValue('enabled', v)} /> +
+
+ + + +
+ + , + ]); + + + return ( + + ); + }) : } + + this.context.router.push('/strategies/create')} + caption="Add" legend="new strategy" leftIcon="add" /> + + ); + } +} + + +export default StrategiesListComponent; diff --git a/frontend/src/component/strategies/list-container.jsx b/frontend/src/component/strategies/list-container.jsx new file mode 100644 index 0000000000..1b1d1eb87e --- /dev/null +++ b/frontend/src/component/strategies/list-container.jsx @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import StrategiesListComponent from './list-component.jsx'; +import { fetchStrategies, removeStrategy } from '../../store/strategy-actions'; + +const mapStateToProps = (state) => { + const list = state.strategies.get('list').toArray(); + + return { + strategies: list, + }; +}; + +const mapDispatchToProps = (dispatch) => ({ + removeStrategy: (strategy) => { + if (window.confirm('Are you sure you want to remove this strategy?')) { // eslint-disable-line no-alert + removeStrategy(strategy)(dispatch); + } + }, + fetchStrategies: () => fetchStrategies()(dispatch), +}); + +const StrategiesListContainer = connect(mapStateToProps, mapDispatchToProps)(StrategiesListComponent); + +export default StrategiesListContainer; diff --git a/frontend/src/component/strategies/strategies.scss b/frontend/src/component/strategies/strategies.scss new file mode 100644 index 0000000000..544d25fa08 --- /dev/null +++ b/frontend/src/component/strategies/strategies.scss @@ -0,0 +1,8 @@ +.non-style-button { + cursor: pointer; + color: #757575; + background: none; + border: 0; + padding: 0; + margin: 0; +} diff --git a/frontend/src/component/styles.scss b/frontend/src/component/styles.scss new file mode 100644 index 0000000000..2bb912f3e9 --- /dev/null +++ b/frontend/src/component/styles.scss @@ -0,0 +1,17 @@ +.container { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + width: 100%; + height: auto; + overflow-y: auto; +} + +.navigation { + .active { + background-color: #EEE; + } +} + diff --git a/frontend/src/data/archive-api.js b/frontend/src/data/archive-api.js new file mode 100644 index 0000000000..29f6ae3743 --- /dev/null +++ b/frontend/src/data/archive-api.js @@ -0,0 +1,24 @@ +import { throwIfNotSuccess, headers } from './helper'; + +const URI = '/archive'; + +function fetchAll () { + return fetch(`${URI}/features`) + .then(throwIfNotSuccess) + .then(response => response.json()); +} + +function revive (feature) { + return fetch(`${URI}/revive`, { + method: 'POST', + headers, + body: JSON.stringify(feature), + }).then(throwIfNotSuccess); +} + + +module.exports = { + fetchAll, + revive, +}; + diff --git a/frontend/src/data/client-instance-api.js b/frontend/src/data/client-instance-api.js new file mode 100644 index 0000000000..bb56fd43a2 --- /dev/null +++ b/frontend/src/data/client-instance-api.js @@ -0,0 +1,13 @@ +import { throwIfNotSuccess, headers } from './helper'; + +const URI = '/client/instances'; + +function fetchAll () { + return fetch(URI, { headers }) + .then(throwIfNotSuccess) + .then(response => response.json()); +} + +module.exports = { + fetchAll, +}; diff --git a/frontend/src/data/client-strategy-api.js b/frontend/src/data/client-strategy-api.js new file mode 100644 index 0000000000..d97642f233 --- /dev/null +++ b/frontend/src/data/client-strategy-api.js @@ -0,0 +1,13 @@ +import { throwIfNotSuccess, headers } from './helper'; + +const URI = '/client/strategies'; + +function fetchAll () { + return fetch(URI, { headers }) + .then(throwIfNotSuccess) + .then(response => response.json()); +} + +module.exports = { + fetchAll, +}; diff --git a/frontend/src/data/feature-api.js b/frontend/src/data/feature-api.js new file mode 100644 index 0000000000..5cf22290f8 --- /dev/null +++ b/frontend/src/data/feature-api.js @@ -0,0 +1,48 @@ +import { throwIfNotSuccess, headers } from './helper'; + +const URI = '/features'; +const URI_VALIDATE = '/features-validate'; + +function fetchAll () { + return fetch(URI) + .then(throwIfNotSuccess) + .then(response => response.json()); +} + +function create (featureToggle) { + return fetch(URI, { + method: 'POST', + headers, + body: JSON.stringify(featureToggle), + }).then(throwIfNotSuccess); +} + +function validate (featureToggle) { + return fetch(URI_VALIDATE, { + method: 'POST', + headers, + body: JSON.stringify(featureToggle), + }).then(throwIfNotSuccess); +} + +function update (featureToggle) { + return fetch(`${URI}/${featureToggle.name}`, { + method: 'PUT', + headers, + body: JSON.stringify(featureToggle), + }).then(throwIfNotSuccess); +} + +function remove (featureToggleName) { + return fetch(`${URI}/${featureToggleName}`, { + method: 'DELETE', + }).then(throwIfNotSuccess); +} + +module.exports = { + fetchAll, + create, + validate, + update, + remove, +}; diff --git a/frontend/src/data/helper.js b/frontend/src/data/helper.js new file mode 100644 index 0000000000..4d396f379d --- /dev/null +++ b/frontend/src/data/helper.js @@ -0,0 +1,25 @@ +const defaultErrorMessage = 'Unexptected exception when talking to unleash-api'; + +export function throwIfNotSuccess (response) { + if (!response.ok) { + if (response.status > 399 && response.status < 404) { + return new Promise((resolve, reject) => { + response.json().then(body => { + const errorMsg = body && body.length > 0 ? body[0].msg : defaultErrorMessage; + let error = new Error(errorMsg); + error.statusCode = response.status; + reject(error); + }); + }); + } else { + return Promise.reject(new Error(defaultErrorMessage)); + } + } + return Promise.resolve(response); +}; + + +export const headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json', +}; diff --git a/frontend/src/data/history-api.js b/frontend/src/data/history-api.js new file mode 100644 index 0000000000..48037fa52a --- /dev/null +++ b/frontend/src/data/history-api.js @@ -0,0 +1,13 @@ +import { throwIfNotSuccess } from './helper'; + +const URI = '/events'; + +function fetchAll () { + return fetch(URI) + .then(throwIfNotSuccess) + .then(response => response.json()); +} + +module.exports = { + fetchAll, +}; diff --git a/frontend/src/data/metrics-api.js b/frontend/src/data/metrics-api.js new file mode 100644 index 0000000000..dbbdf19b76 --- /dev/null +++ b/frontend/src/data/metrics-api.js @@ -0,0 +1,13 @@ +import { throwIfNotSuccess } from './helper'; + +const URI = '/metrics'; + +function fetchAll () { + return fetch(URI) + .then(throwIfNotSuccess) + .then(response => response.json()); +} + +module.exports = { + fetchAll, +}; diff --git a/frontend/src/data/strategy-api.js b/frontend/src/data/strategy-api.js new file mode 100644 index 0000000000..9aa061fe10 --- /dev/null +++ b/frontend/src/data/strategy-api.js @@ -0,0 +1,30 @@ +import { throwIfNotSuccess, headers } from './helper'; + +const URI = '/strategies'; + +function fetchAll () { + return fetch(URI) + .then(throwIfNotSuccess) + .then(response => response.json()); +} + +function create (strategy) { + return fetch(URI, { + method: 'POST', + headers, + body: JSON.stringify(strategy), + }).then(throwIfNotSuccess); +} + +function remove (strategy) { + return fetch(`${URI}/${strategy.name}`, { + method: 'DELETE', + headers, + }).then(throwIfNotSuccess); +} + +module.exports = { + fetchAll, + create, + remove, +}; diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx new file mode 100644 index 0000000000..84fb685f3e --- /dev/null +++ b/frontend/src/index.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Router, Route, IndexRedirect, hashHistory } from 'react-router'; +import { Provider } from 'react-redux'; +import thunkMiddleware from 'redux-thunk'; +import { createStore, applyMiddleware } from 'redux'; + +import store from './store'; +import App from './component/app'; + +import Features from './page/features'; +import CreateFeatureToggle from './page/features/create'; +import EditFeatureToggle from './page/features/edit'; +import Strategies from './page/strategies'; +import CreateStrategies from './page/strategies/create'; +import HistoryPage from './page/history'; +import Archive from './page/archive'; +import Metrics from './page/metrics'; +import ClientStrategies from './page/client-strategies'; +import ClientInstances from './page/client-instances'; + +const unleashStore = createStore( + store, + applyMiddleware( + thunkMiddleware + ) +); + +ReactDOM.render( + + + + + + + + + + + + + + + + + , document.getElementById('app')); diff --git a/frontend/src/page/archive/index.js b/frontend/src/page/archive/index.js new file mode 100644 index 0000000000..c88ee8d0e1 --- /dev/null +++ b/frontend/src/page/archive/index.js @@ -0,0 +1,6 @@ +import React from 'react'; +import Archive from '../../component/archive/archive-container'; + +const render = () => ; + +export default render; diff --git a/frontend/src/page/client-instances/index.js b/frontend/src/page/client-instances/index.js new file mode 100644 index 0000000000..75b27b52da --- /dev/null +++ b/frontend/src/page/client-instances/index.js @@ -0,0 +1,6 @@ +import React from 'react'; +import ClientInstance from '../../component/client-instance/client-instance-container'; + +const render = () => ; + +export default render; diff --git a/frontend/src/page/client-strategies/index.js b/frontend/src/page/client-strategies/index.js new file mode 100644 index 0000000000..1c5e66849c --- /dev/null +++ b/frontend/src/page/client-strategies/index.js @@ -0,0 +1,11 @@ +import React from 'react'; +import ClientStrategy from '../../component/client-strategy/strategy-container'; + +const render = () => ( +
+
Client Strategies
+ +
+); + +export default render; diff --git a/frontend/src/page/features/create.js b/frontend/src/page/features/create.js new file mode 100644 index 0000000000..ff9afd5e6f --- /dev/null +++ b/frontend/src/page/features/create.js @@ -0,0 +1,11 @@ +import React from 'react'; +import AddFeatureToggleForm from '../../component/feature/form-add-container'; + +const render = () => ( +
+

Create feature toggle

+ +
+); + +export default render; diff --git a/frontend/src/page/features/edit.js b/frontend/src/page/features/edit.js new file mode 100644 index 0000000000..fed77e7f18 --- /dev/null +++ b/frontend/src/page/features/edit.js @@ -0,0 +1,19 @@ +import React, { Component, PropTypes } from 'react'; +import EditFeatureToggleForm from '../../component/feature/form-edit-container'; + +export default class Features extends Component { + static propTypes () { + return { + params: PropTypes.object.isRequired, + }; + } + + render () { + return ( +
+

Edit feature toggle

+ +
+ ); + } +}; diff --git a/frontend/src/page/features/index.js b/frontend/src/page/features/index.js new file mode 100644 index 0000000000..b632addcd0 --- /dev/null +++ b/frontend/src/page/features/index.js @@ -0,0 +1,8 @@ +import React from 'react'; +import FeatureListContainer from '../../component/feature/list-container'; + +const render = () => (); + +export default render; + + diff --git a/frontend/src/page/history/index.js b/frontend/src/page/history/index.js new file mode 100644 index 0000000000..8b2b5a8c3f --- /dev/null +++ b/frontend/src/page/history/index.js @@ -0,0 +1,6 @@ +import React from 'react'; +import HistoryComponent from '../../component/history/history-container'; + +const render = () => ; + +export default render; diff --git a/frontend/src/page/metrics/index.js b/frontend/src/page/metrics/index.js new file mode 100644 index 0000000000..c18da93b5c --- /dev/null +++ b/frontend/src/page/metrics/index.js @@ -0,0 +1,6 @@ +import React from 'react'; +import Metrics from '../../component/metrics/metrics-container'; + +const render = () => ; + +export default render; diff --git a/frontend/src/page/strategies/create.js b/frontend/src/page/strategies/create.js new file mode 100644 index 0000000000..bd94fcf7d1 --- /dev/null +++ b/frontend/src/page/strategies/create.js @@ -0,0 +1,4 @@ +import React from 'react'; +import AddStrategies from '../../component/strategies/add-container'; + +export default () => (); diff --git a/frontend/src/page/strategies/index.js b/frontend/src/page/strategies/index.js new file mode 100644 index 0000000000..8f41038bd0 --- /dev/null +++ b/frontend/src/page/strategies/index.js @@ -0,0 +1,4 @@ +import React from 'react'; +import Strategies from '../../component/strategies/list-container'; + +export default () => (); diff --git a/frontend/src/store/archive-actions.js b/frontend/src/store/archive-actions.js new file mode 100644 index 0000000000..07ea4cbc6d --- /dev/null +++ b/frontend/src/store/archive-actions.js @@ -0,0 +1,33 @@ +import api from '../data/archive-api'; + +export const REVIVE_TOGGLE = 'REVIVE_TOGGLE'; +export const RECEIVE_ARCHIVE = 'RECEIVE_ARCHIVE'; +export const ERROR_RECEIVE_ARCHIVE = 'ERROR_RECEIVE_ARCHIVE'; + +const receiveArchive = (json) => ({ + type: RECEIVE_ARCHIVE, + value: json.features, +}); + +const reviveToggle = (archiveFeatureToggle) => ({ + type: REVIVE_TOGGLE, + value: archiveFeatureToggle, +}); + +const errorReceiveArchive = (statusCode) => ({ + type: ERROR_RECEIVE_ARCHIVE, + statusCode, +}); + +export function revive (featureToggle) { + return dispatch => api.revive(featureToggle) + .then(() => dispatch(reviveToggle(featureToggle))) + .catch(error => dispatch(errorReceiveArchive(error))); +} + + +export function fetchArchive () { + return dispatch => api.fetchAll() + .then(json => dispatch(receiveArchive(json))) + .catch(error => dispatch(errorReceiveArchive(error))); +} diff --git a/frontend/src/store/archive-store.js b/frontend/src/store/archive-store.js new file mode 100644 index 0000000000..845b79d748 --- /dev/null +++ b/frontend/src/store/archive-store.js @@ -0,0 +1,19 @@ +import { List, Map as $Map } from 'immutable'; +import { RECEIVE_ARCHIVE, REVIVE_TOGGLE } from './archive-actions'; + +function getInitState () { + return new $Map({ list: new List() }); +} + +const archiveStore = (state = getInitState(), action) => { + switch (action.type) { + case REVIVE_TOGGLE: + return state.update('list', (list) => list.remove(list.indexOf(action.value))); + case RECEIVE_ARCHIVE: + return state.set('list', new List(action.value)); + default: + return state; + } +}; + +export default archiveStore; diff --git a/frontend/src/store/client-instance-actions.js b/frontend/src/store/client-instance-actions.js new file mode 100644 index 0000000000..03de9edcf5 --- /dev/null +++ b/frontend/src/store/client-instance-actions.js @@ -0,0 +1,20 @@ +import api from '../data/client-instance-api'; + +export const RECEIVE_CLIENT_INSTANCES = 'RECEIVE_CLIENT_INSTANCES'; +export const ERROR_RECEIVE_CLIENT_INSTANCES = 'ERROR_RECEIVE_CLIENT_INSTANCES'; + +const receiveClientInstances = (json) => ({ + type: RECEIVE_CLIENT_INSTANCES, + value: json, +}); + +const errorReceiveClientInstances = (statusCode) => ({ + type: RECEIVE_CLIENT_INSTANCES, + statusCode, +}); + +export function fetchClientInstances () { + return dispatch => api.fetchAll() + .then(json => dispatch(receiveClientInstances(json))) + .catch(error => dispatch(errorReceiveClientInstances(error))); +} diff --git a/frontend/src/store/client-instance-store.js b/frontend/src/store/client-instance-store.js new file mode 100644 index 0000000000..d075fbf841 --- /dev/null +++ b/frontend/src/store/client-instance-store.js @@ -0,0 +1,17 @@ +import { fromJS } from 'immutable'; +import { RECEIVE_CLIENT_INSTANCES } from './client-instance-actions'; + +function getInitState () { + return fromJS([]); +} + +const store = (state = getInitState(), action) => { + switch (action.type) { + case RECEIVE_CLIENT_INSTANCES: + return fromJS(action.value); + default: + return state; + } +}; + +export default store; diff --git a/frontend/src/store/client-strategy-actions.js b/frontend/src/store/client-strategy-actions.js new file mode 100644 index 0000000000..b7a07a0dad --- /dev/null +++ b/frontend/src/store/client-strategy-actions.js @@ -0,0 +1,20 @@ +import api from '../data/client-strategy-api'; + +export const RECEIVE_CLIENT_STRATEGIES = 'RECEIVE_CLIENT_STRATEGIES'; +export const ERROR_RECEIVE_CLIENT_STRATEGIES = 'ERROR_RECEIVE_CLIENT_STRATEGIES'; + +const receiveMetrics = (json) => ({ + type: RECEIVE_CLIENT_STRATEGIES, + value: json, +}); + +const errorReceiveMetrics = (statusCode) => ({ + type: RECEIVE_CLIENT_STRATEGIES, + statusCode, +}); + +export function fetchClientStrategies () { + return dispatch => api.fetchAll() + .then(json => dispatch(receiveMetrics(json))) + .catch(error => dispatch(errorReceiveMetrics(error))); +} diff --git a/frontend/src/store/client-strategy-store.js b/frontend/src/store/client-strategy-store.js new file mode 100644 index 0000000000..1de24b1be0 --- /dev/null +++ b/frontend/src/store/client-strategy-store.js @@ -0,0 +1,17 @@ +import { fromJS } from 'immutable'; +import { RECEIVE_CLIENT_STRATEGIES } from './client-strategy-actions'; + +function getInitState () { + return fromJS([]); +} + +const store = (state = getInitState(), action) => { + switch (action.type) { + case RECEIVE_CLIENT_STRATEGIES: + return fromJS(action.value); + default: + return state; + } +}; + +export default store; diff --git a/frontend/src/store/error-actions.js b/frontend/src/store/error-actions.js new file mode 100644 index 0000000000..5b8a3682df --- /dev/null +++ b/frontend/src/store/error-actions.js @@ -0,0 +1,8 @@ +export const MUTE_ERRORS = 'MUTE_ERRORS'; +export const MUTE_ERROR = 'MUTE_ERROR'; + +export const muteErrors = () => ({ type: MUTE_ERRORS }); + +export const muteError = (error) => ({ type: MUTE_ERROR, error }); + + diff --git a/frontend/src/store/error-store.js b/frontend/src/store/error-store.js new file mode 100644 index 0000000000..129f8b6c37 --- /dev/null +++ b/frontend/src/store/error-store.js @@ -0,0 +1,40 @@ +import { List, Map as $Map } from 'immutable'; +import { MUTE_ERROR } from './error-actions'; +import { + ERROR_FETCH_FEATURE_TOGGLES, + ERROR_CREATING_FEATURE_TOGGLE, + ERROR_REMOVE_FEATURE_TOGGLE, + ERROR_UPDATE_FEATURE_TOGGLE, +} from './feature-actions'; + +const debug = require('debug')('unleash:error-store'); + +function getInitState () { + return new $Map({ + list: new List(), + }); +} + +function addErrorIfNotAlreadyInList (state, error) { + debug('Got error', error); + if (state.get('list').indexOf(error) < 0) { + return state.update('list', (list) => list.push(error)); + } + return state; +} + +const strategies = (state = getInitState(), action) => { + switch (action.type) { + case ERROR_CREATING_FEATURE_TOGGLE: + case ERROR_REMOVE_FEATURE_TOGGLE: + case ERROR_FETCH_FEATURE_TOGGLES: + case ERROR_UPDATE_FEATURE_TOGGLE: + return addErrorIfNotAlreadyInList(state, action.error.message); + case MUTE_ERROR: + return state.update('list', (list) => list.remove(list.indexOf(action.error))); + default: + return state; + } +}; + +export default strategies; diff --git a/frontend/src/store/feature-actions.js b/frontend/src/store/feature-actions.js new file mode 100644 index 0000000000..c21304f8b7 --- /dev/null +++ b/frontend/src/store/feature-actions.js @@ -0,0 +1,93 @@ +import api from '../data/feature-api'; +const debug = require('debug')('unleash:feature-actions'); + +export const ADD_FEATURE_TOGGLE = 'ADD_FEATURE_TOGGLE'; +export const REMOVE_FEATURE_TOGGLE = 'REMOVE_FEATURE_TOGGLE'; +export const UPDATE_FEATURE_TOGGLE = 'UPDATE_FEATURE_TOGGLE'; +export const TOGGLE_FEATURE_TOGGLE = 'TOGGLE_FEATURE_TOGGLE'; +export const START_FETCH_FEATURE_TOGGLES = 'START_FETCH_FEATURE_TOGGLES'; +export const START_UPDATE_FEATURE_TOGGLE = 'START_UPDATE_FEATURE_TOGGLE'; +export const START_CREATE_FEATURE_TOGGLE = 'START_CREATE_FEATURE_TOGGLE'; +export const START_REMOVE_FEATURE_TOGGLE = 'START_REMOVE_FEATURE_TOGGLE'; +export const RECEIVE_FEATURE_TOGGLES = 'RECEIVE_FEATURE_TOGGLES'; +export const ERROR_FETCH_FEATURE_TOGGLES = 'ERROR_FETCH_FEATURE_TOGGLES'; +export const ERROR_CREATING_FEATURE_TOGGLE = 'ERROR_CREATING_FEATURE_TOGGLE'; +export const ERROR_UPDATE_FEATURE_TOGGLE = 'ERROR_UPDATE_FEATURE_TOGGLE'; +export const ERROR_REMOVE_FEATURE_TOGGLE = 'ERROR_REMOVE_FEATURE_TOGGLE'; + +export function toggleFeature (featureToggle) { + debug('Toggle feature toggle ', featureToggle); + return dispatch => { + const newValue = Object.assign({}, featureToggle, { enabled: !featureToggle.enabled }); + dispatch(requestUpdateFeatureToggle(newValue)); + }; +}; + +export function editFeatureToggle (featureToggle) { + debug('Update feature toggle ', featureToggle); + return dispatch => { + dispatch(requestUpdateFeatureToggle(featureToggle)); + }; +}; + + +function receiveFeatureToggles (json) { + debug('reviced feature toggles', json); + return { + type: RECEIVE_FEATURE_TOGGLES, + featureToggles: json.features.map(features => features), + receivedAt: Date.now(), + }; +} + +function dispatchAndThrow (dispatch, type) { + return (error) => { + dispatch({ type, error, receivedAt: Date.now() }); + throw error; + }; +} + +export function fetchFeatureToggles () { + debug('Start fetching feature toggles'); + return dispatch => { + dispatch({ type: START_FETCH_FEATURE_TOGGLES }); + + return api.fetchAll() + .then(json => dispatch(receiveFeatureToggles(json))) + .catch(dispatchAndThrow(dispatch, ERROR_FETCH_FEATURE_TOGGLES)); + }; +} + +export function createFeatureToggles (featureToggle) { + return dispatch => { + dispatch({ type: START_CREATE_FEATURE_TOGGLE }); + + return api.create(featureToggle) + .then(() => dispatch({ type: ADD_FEATURE_TOGGLE, featureToggle })) + .catch(dispatchAndThrow(dispatch, ERROR_CREATING_FEATURE_TOGGLE)); + }; +} + +export function requestUpdateFeatureToggle (featureToggle) { + return dispatch => { + dispatch({ type: START_UPDATE_FEATURE_TOGGLE }); + + return api.update(featureToggle) + .then(() => dispatch({ type: UPDATE_FEATURE_TOGGLE, featureToggle })) + .catch(dispatchAndThrow(dispatch, ERROR_UPDATE_FEATURE_TOGGLE)); + }; +} + +export function removeFeatureToggle (featureToggleName) { + return dispatch => { + dispatch({ type: START_REMOVE_FEATURE_TOGGLE }); + + return api.remove(featureToggleName) + .then(() => dispatch({ type: REMOVE_FEATURE_TOGGLE, featureToggleName })) + .catch(dispatchAndThrow(dispatch, ERROR_REMOVE_FEATURE_TOGGLE)); + }; +} + +export function validateName (featureToggleName) { + return api.validate({ name: featureToggleName }); +} diff --git a/frontend/src/store/feature-metrics-actions.js b/frontend/src/store/feature-metrics-actions.js new file mode 100644 index 0000000000..4dc8eb66a6 --- /dev/null +++ b/frontend/src/store/feature-metrics-actions.js @@ -0,0 +1,30 @@ +import api from './feature-metrics-api'; + +export const START_FETCH_FEATURE_METRICS = 'START_FETCH_FEATURE_METRICS'; +export const RECEIVE_FEATURE_METRICS = 'RECEIVE_FEATURE_METRICS'; +export const ERROR_FETCH_FEATURE_TOGGLES = 'ERROR_FETCH_FEATURE_TOGGLES'; + +function receiveFeatureMetrics (json) { + return { + type: RECEIVE_FEATURE_METRICS, + metrics: json, + receivedAt: Date.now(), + }; +} + +function dispatchAndThrow (dispatch, type) { + return (error) => { + dispatch({ type, error, receivedAt: Date.now() }); + throw error; + }; +} + +export function fetchFeatureMetrics () { + return dispatch => { + dispatch({ type: START_FETCH_FEATURE_METRICS }); + + return api.fetchFeatureMetrics() + .then(json => dispatch(receiveFeatureMetrics(json))) + .catch(dispatchAndThrow(dispatch, ERROR_FETCH_FEATURE_TOGGLES)); + }; +} diff --git a/frontend/src/store/feature-metrics-api.js b/frontend/src/store/feature-metrics-api.js new file mode 100644 index 0000000000..cc4d28cac1 --- /dev/null +++ b/frontend/src/store/feature-metrics-api.js @@ -0,0 +1,29 @@ +const defaultErrorMessage = 'Unexptected exception when talking to unleash-api'; + +function throwIfNotSuccess (response) { + if (!response.ok) { + if (response.status > 400 && response.status < 404) { + return new Promise((resolve, reject) => { + response.json().then(body => { + const errorMsg = body && body.length > 0 ? body[0].msg : defaultErrorMessage; + let error = new Error(errorMsg); + error.statusCode = response.status; + reject(error); + }); + }); + } else { + return Promise.reject(new Error(defaultErrorMessage)); + } + } + return Promise.resolve(response); +} + +function fetchFeatureMetrics () { + return fetch('/metrics/features') + .then(throwIfNotSuccess) + .then(response => response.json()); +} + +module.exports = { + fetchFeatureMetrics, +}; diff --git a/frontend/src/store/feature-metrics-store.js b/frontend/src/store/feature-metrics-store.js new file mode 100644 index 0000000000..87d7459686 --- /dev/null +++ b/frontend/src/store/feature-metrics-store.js @@ -0,0 +1,21 @@ +import { Map as $Map, fromJS } from 'immutable'; + +import { + RECEIVE_FEATURE_METRICS, +} from './feature-metrics-actions'; + + +const metrics = (state = fromJS({ lastHour: {}, lastMinute: {} }), action) => { + switch (action.type) { + case RECEIVE_FEATURE_METRICS: + return state.withMutations((ctx) => { + ctx.set('lastHour', new $Map(action.metrics.lastHour)); + ctx.set('lastMinute', new $Map(action.metrics.lastMinute)); + return ctx; + }); + default: + return state; + } +}; + +export default metrics; diff --git a/frontend/src/store/feature-store.js b/frontend/src/store/feature-store.js new file mode 100644 index 0000000000..ef17ff6663 --- /dev/null +++ b/frontend/src/store/feature-store.js @@ -0,0 +1,38 @@ +import { List, Map as $Map } from 'immutable'; +const debug = require('debug')('unleash:feature-store'); + + +import { + ADD_FEATURE_TOGGLE, + RECEIVE_FEATURE_TOGGLES, + UPDATE_FEATURE_TOGGLE, + REMOVE_FEATURE_TOGGLE, +} from './feature-actions'; + + +const features = (state = new List([]), action) => { + switch (action.type) { + case ADD_FEATURE_TOGGLE: + debug(ADD_FEATURE_TOGGLE, action); + return state.push(new $Map(action.featureToggle)); + case REMOVE_FEATURE_TOGGLE: + debug(REMOVE_FEATURE_TOGGLE, action); + return state.filter(toggle => toggle.get('name') !== action.featureToggleName); + case UPDATE_FEATURE_TOGGLE: + debug(UPDATE_FEATURE_TOGGLE, action); + return state.map(toggle => { + if (toggle.get('name') === action.featureToggle.name) { + return new $Map(action.featureToggle); + } else { + return toggle; + } + }); + case RECEIVE_FEATURE_TOGGLES: + debug(RECEIVE_FEATURE_TOGGLES, action); + return new List(action.featureToggles.map($Map)); + default: + return state; + } +}; + +export default features; diff --git a/frontend/src/store/history-actions.js b/frontend/src/store/history-actions.js new file mode 100644 index 0000000000..7a45e89d0c --- /dev/null +++ b/frontend/src/store/history-actions.js @@ -0,0 +1,20 @@ +import api from '../data/history-api'; + +export const RECEIVE_HISTORY = 'RECEIVE_HISTORY'; +export const ERROR_RECEIVE_HISTORY = 'ERROR_RECEIVE_HISTORY'; + +const receiveHistory = (json) => ({ + type: RECEIVE_HISTORY, + value: json.events, +}); + +const errorReceiveHistory = (statusCode) => ({ + type: ERROR_RECEIVE_HISTORY, + statusCode, +}); + +export function fetchHistory () { + return dispatch => api.fetchAll() + .then(json => dispatch(receiveHistory(json))) + .catch(error => dispatch(errorReceiveHistory(error))); +} diff --git a/frontend/src/store/history-store.js b/frontend/src/store/history-store.js new file mode 100644 index 0000000000..790d85ec18 --- /dev/null +++ b/frontend/src/store/history-store.js @@ -0,0 +1,17 @@ +import { List, Map as $Map } from 'immutable'; +import { RECEIVE_HISTORY } from './history-actions'; + +function getInitState () { + return new $Map({ list: new List() }); +} + +const historyStore = (state = getInitState(), action) => { + switch (action.type) { + case RECEIVE_HISTORY: + return state.set('list', new List(action.value)); + default: + return state; + } +}; + +export default historyStore; diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js new file mode 100644 index 0000000000..2710c6cc1e --- /dev/null +++ b/frontend/src/store/index.js @@ -0,0 +1,26 @@ +import { combineReducers } from 'redux'; +import features from './feature-store'; +import featureMetrics from './feature-metrics-store'; +import strategies from './strategy-store'; +import input from './input-store'; +import history from './history-store'; // eslint-disable-line +import archive from './archive-store'; +import error from './error-store'; +import metrics from './metrics-store'; +import clientStrategies from './client-strategy-store'; +import clientInstances from './client-instance-store'; + +const unleashStore = combineReducers({ + features, + featureMetrics, + strategies, + input, + history, + archive, + error, + metrics, + clientStrategies, + clientInstances, +}); + +export default unleashStore; diff --git a/frontend/src/store/input-actions.js b/frontend/src/store/input-actions.js new file mode 100644 index 0000000000..f94fe5a09f --- /dev/null +++ b/frontend/src/store/input-actions.js @@ -0,0 +1,19 @@ +export const actions = { + SET_VALUE: 'SET_VALUE', + INCREMENT_VALUE: 'INCREMENT_VALUE', + LIST_PUSH: 'LIST_PUSH', + LIST_POP: 'LIST_POP', + LIST_UP: 'LIST_UP', + CLEAR: 'CLEAR', + INIT: 'INIT', +}; + +export const createInit = ({ id, value }) => ({ type: actions.INIT, id, value }); +export const createInc = ({ id, key }) => ({ type: actions.INCREMENT_VALUE, id, key }); +export const createSet = ({ id, key, value }) => ({ type: actions.SET_VALUE, id, key, value }); +export const createPush = ({ id, key, value }) => ({ type: actions.LIST_PUSH, id, key, value }); +export const createPop = ({ id, key, value }) => ({ type: actions.LIST_POP, id, key, value }); +export const createUp = ({ id, key, value, newValue }) => ({ type: actions.LIST_UP, id, key, value, newValue }); +export const createClear = ({ id }) => ({ type: actions.CLEAR, id }); + +export default actions; diff --git a/frontend/src/store/input-store.js b/frontend/src/store/input-store.js new file mode 100644 index 0000000000..3379123398 --- /dev/null +++ b/frontend/src/store/input-store.js @@ -0,0 +1,94 @@ +import { Map as $Map, List, fromJS } from 'immutable'; +import actions from './input-actions'; + +function getInitState () { + return new $Map(); +} + +function init (state, { id, value }) { + state = assertId(state, id); + return state.setIn(id, fromJS(value)); +} + +function assertId (state, id) { + if (!state.hasIn(id)) { + return state.setIn(id, new $Map({ inputId: id })); + } + return state; +} + +function assertList (state, id, key) { + if (!state.getIn(id).has(key)) { + return state.setIn(id.concat([key]), new List()); + } + return state; +} + +function setKeyValue (state, { id, key, value }) { + state = assertId(state, id); + return state.setIn(id.concat([key]), value); +} + +function increment (state, { id, key }) { + state = assertId(state, id); + return state.updateIn(id.concat([key]), (value = 0) => value + 1); +} + +function clear (state, { id }) { + if (state.hasIn(id)) { + return state.removeIn(id); + } + return state; +} + +function addToList (state, { id, key, value }) { + state = assertId(state, id); + state = assertList(state, id, key); + + return state.updateIn(id.concat([key]), (list) => list.push(value)); +} + +function updateInList (state, { id, key, value, newValue }) { + state = assertId(state, id); + state = assertList(state, id, key); + + return state.updateIn(id.concat([key]), (list) => list.set(list.indexOf(value), newValue)); +} + +function removeFromList (state, { id, key, value }) { + state = assertId(state, id); + state = assertList(state, id, key); + + return state.updateIn(id.concat([key]), (list) => list.remove(list.indexOf(value))); +} + +const inputState = (state = getInitState(), action) => { + if (!action.id) { + return state; + } + + switch (action.type) { + case actions.INIT: + return init(state, action); + case actions.SET_VALUE: + if (actions.key != null && actions.value != null) { + throw new Error('Missing required key / value'); + } + return setKeyValue(state, action); + case actions.INCREMENT_VALUE: + return increment(state, action); + case actions.LIST_PUSH: + return addToList(state, action); + case actions.LIST_POP: + return removeFromList(state, action); + case actions.LIST_UP: + return updateInList(state, action); + case actions.CLEAR: + return clear(state, action); + default: + // console.log('TYPE', action.type, action); + return state; + } +}; + +export default inputState; diff --git a/frontend/src/store/metrics-actions.js b/frontend/src/store/metrics-actions.js new file mode 100644 index 0000000000..7010d3857d --- /dev/null +++ b/frontend/src/store/metrics-actions.js @@ -0,0 +1,20 @@ +import api from '../data/metrics-api'; + +export const RECEIVE_METRICS = 'RECEIVE_METRICS'; +export const ERROR_RECEIVE_METRICS = 'ERROR_RECEIVE_METRICS'; + +const receiveMetrics = (json) => ({ + type: RECEIVE_METRICS, + value: json, +}); + +const errorReceiveMetrics = (statusCode) => ({ + type: ERROR_RECEIVE_METRICS, + statusCode, +}); + +export function fetchMetrics () { + return dispatch => api.fetchAll() + .then(json => dispatch(receiveMetrics(json))) + .catch(error => dispatch(errorReceiveMetrics(error))); +} diff --git a/frontend/src/store/metrics-store.js b/frontend/src/store/metrics-store.js new file mode 100644 index 0000000000..f0b7a4a650 --- /dev/null +++ b/frontend/src/store/metrics-store.js @@ -0,0 +1,21 @@ +import { fromJS } from 'immutable'; +import { RECEIVE_METRICS } from './metrics-actions'; + +function getInitState () { + return fromJS({ + totalCount: 0, + apps: [], + clients: {}, + }); +} + +const historyStore = (state = getInitState(), action) => { + switch (action.type) { + case RECEIVE_METRICS: + return fromJS(action.value); + default: + return state; + } +}; + +export default historyStore; diff --git a/frontend/src/store/strategy-actions.js b/frontend/src/store/strategy-actions.js new file mode 100644 index 0000000000..8421a1384c --- /dev/null +++ b/frontend/src/store/strategy-actions.js @@ -0,0 +1,61 @@ +import api from '../data/strategy-api'; + +export const ADD_STRATEGY = 'ADD_STRATEGY'; +export const REMOVE_STRATEGY = 'REMOVE_STRATEGY'; +export const REQUEST_STRATEGIES = 'REQUEST_STRATEGIES'; +export const START_CREATE_STRATEGY = 'START_CREATE_STRATEGY'; +export const RECEIVE_STRATEGIES = 'RECEIVE_STRATEGIES'; +export const ERROR_RECEIVE_STRATEGIES = 'ERROR_RECEIVE_STRATEGIES'; +export const ERROR_CREATING_STRATEGY = 'ERROR_CREATING_STRATEGY'; + +const addStrategy = (strategy) => ({ type: ADD_STRATEGY, strategy }); +const createRemoveStrategy = (strategy) => ({ type: REMOVE_STRATEGY, strategy }); + +const errorCreatingStrategy = (statusCode) => ({ + type: ERROR_CREATING_STRATEGY, + statusCode, +}); + +const startRequest = () => ({ type: REQUEST_STRATEGIES }); + + +const receiveStrategies = (json) => ({ + type: RECEIVE_STRATEGIES, + value: json.strategies, +}); + +const startCreate = () => ({ type: START_CREATE_STRATEGY }); + +const errorReceiveStrategies = (statusCode) => ({ + type: ERROR_RECEIVE_STRATEGIES, + statusCode, +}); + +export function fetchStrategies () { + return dispatch => { + dispatch(startRequest()); + + return api.fetchAll() + .then(json => dispatch(receiveStrategies(json))) + .catch(error => dispatch(errorReceiveStrategies(error))); + }; +} + +export function createStrategy (strategy) { + return dispatch => { + dispatch(startCreate()); + + return api.create(strategy) + .then(() => dispatch(addStrategy(strategy))) + .catch(error => dispatch(errorCreatingStrategy(error))); + }; +} + + +export function removeStrategy (strategy) { + return dispatch => api.remove(strategy) + .then(() => dispatch(createRemoveStrategy(strategy))) + .catch(error => dispatch(errorCreatingStrategy(error))); +} + + diff --git a/frontend/src/store/strategy-store.js b/frontend/src/store/strategy-store.js new file mode 100644 index 0000000000..72900eaf22 --- /dev/null +++ b/frontend/src/store/strategy-store.js @@ -0,0 +1,29 @@ +import { List, Map as $Map } from 'immutable'; +import { RECEIVE_STRATEGIES, REMOVE_STRATEGY, ADD_STRATEGY } from './strategy-actions'; + +function getInitState () { + return new $Map({ list: new List() }); +} + +function removeStrategy (state, action) { + const indexToRemove = state.get('list').indexOf(action.strategy); + if (indexToRemove !== -1) { + return state.update('list', (list) => list.remove(indexToRemove)); + } + return state; +} + +const strategies = (state = getInitState(), action) => { + switch (action.type) { + case RECEIVE_STRATEGIES: + return state.set('list', new List(action.value)); + case REMOVE_STRATEGY: + return removeStrategy(state, action); + case ADD_STRATEGY: + return state.update('list', (list) => list.push(action.strategy)); + default: + return state; + } +}; + +export default strategies; diff --git a/frontend/src/theme/_config.scss b/frontend/src/theme/_config.scss new file mode 100644 index 0000000000..23243086ea --- /dev/null +++ b/frontend/src/theme/_config.scss @@ -0,0 +1,31 @@ +@import "~react-toolbox/lib/colors"; +@import "~react-toolbox/lib/globals"; +@import "~react-toolbox/lib/mixins"; +@import "~react-toolbox/lib/commons"; + +$color-primary:$palette-blue-400; +$color-primary-dark: $palette-blue-700; + +$navigation-drawer-desktop-width: 4 * $standard-increment-desktop !default; +$navigation-drawer-max-desktop-width: 70 * $unit !default; + +// Mobile: +// Width = Screen width − 56 dp +// Maximum width: 320dp +$navigation-drawer-mobile-width: 5 * $standard-increment-mobile !default; + +// sass doesn't like use of variable here: calc(100% - $standard-increment-mobile); +$navigation-drawer-max-mobile-width: calc(100% - 5.6rem) !default; + +.appBar { + .leftIcon { + transition-timing-function: $animation-curve-default; + transition-duration: $animation-duration; + transition-property: width, min-width; + } + @media screen and (min-width: $layout-breakpoint-sm) { + .leftIcon { + display: none; + } + } +} \ No newline at end of file diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js new file mode 100644 index 0000000000..b3d338f878 --- /dev/null +++ b/frontend/webpack.config.js @@ -0,0 +1,86 @@ +// docs: http://webpack.github.io/docs/configuration.html +'use strict'; + +const path = require('path'); +const webpack = require('webpack'); +const ExtractTextPlugin = require('extract-text-webpack-plugin'); + +module.exports = { + entry: [ + 'webpack-dev-server/client?http://localhost:3000', + 'webpack/hot/only-dev-server', + './src/index', + ], + + resolve: { + root: [path.join(__dirname, 'src')], + extensions: ['', '.scss', '.css', '.js', '.jsx', '.json'], + }, + + output: { + path: path.join(__dirname, 'dist'), + filename: 'bundle.js', + publicPath: '/static/', + }, + + module: { + loaders: [ + { + test: /\.jsx?$/, + exclude: /node_modules/, + loaders: ['babel'], + include: path.join(__dirname, 'src'), + }, + { + test: /(\.scss|\.css)$/, + loader: ExtractTextPlugin.extract('style', + 'css?sourceMap&modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]!postcss!sass'), + }, + ], + }, + + plugins: [ + new ExtractTextPlugin('bundle.css', { allChunks: true }), + new webpack.HotModuleReplacementPlugin(), + ], + + sassLoader: { + data: '@import "theme/_config.scss";', + includePaths: [path.resolve(__dirname, './src')], + }, + + devtool: 'source-map', + + externals: { + // stuff not in node_modules can be resolved here. + }, + + devServer: { + proxy: { + '/features': { + target: 'http://localhost:4242', + secure: false, + }, + '/strategies': { + target: 'http://localhost:4242', + secure: false, + }, + '/archive': { + target: 'http://localhost:4242', + secure: false, + }, + '/events': { + target: 'http://localhost:4242', + secure: false, + }, + '/metrics': { + target: 'http://localhost:4242', + secure: false, + }, + '/client': { + target: 'http://localhost:4242', + secure: false, + }, + }, + }, +};