diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 0000000000..afff24bb76 --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,16 @@ +# editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.json] +indent_size = 2 diff --git a/frontend/.gitignore b/frontend/.gitignore index 9dacf2eafe..12b10741f1 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -36,3 +36,4 @@ jspm_packages # Optional REPL history .node_repl_history +typings* diff --git a/frontend/index.html b/frontend/index.html index 4ab51d9e47..25970fa8af 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,6 @@ Unleash Admin - diff --git a/frontend/package.json b/frontend/package.json index 810ad1af73..d228354ffc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,7 +22,7 @@ "build": "npm run build:assets && npm run build:html", "build:assets": "NODE_ENV=production webpack -p", "build:html": "cp public/*.* dist/.", - "start": "NODE_ENV=development webpack-dev-server --config webpack.config.js --hot --progress --colors --port 3000", + "start": "NODE_ENV=development webpack-dev-server --config webpack.config.js --progress --colors --port 3000", "lint": "eslint . --ext=js,jsx", "test": "echo 'no test'", "test:ci": "npm run test", @@ -34,7 +34,6 @@ "immutability-helper": "^2.0.0", "immutable": "^3.8.1", "normalize.css": "^5.0.0", - "percent": "^2.0.0", "react": "^15.3.1", "react-addons-css-transition-group": "^15.3.1", "react-dom": "^15.3.1", diff --git a/frontend/src/.eslintrc b/frontend/src/.eslintrc index 67c2bde374..d69a90d3ee 100644 --- a/frontend/src/.eslintrc +++ b/frontend/src/.eslintrc @@ -20,6 +20,7 @@ } }, "rules": { - "no-shadow": 0 + "no-shadow": 0, + "react/sort-comp": 0 } } diff --git a/frontend/src/component/app.jsx b/frontend/src/component/app.jsx index da7081e7c9..c9b70992fd 100644 --- a/frontend/src/component/app.jsx +++ b/frontend/src/component/app.jsx @@ -1,52 +1,114 @@ -import React, { Component } from 'react'; +import React, { Component, PropTypes } from 'react'; import { Layout, Drawer, Header, Navigation, Content, Footer, FooterSection, FooterDropDownSection, FooterLinkList, - Grid, Cell, + Grid, Cell, Icon, } from 'react-mdl'; +import { Link } from 'react-router'; import style from './styles.scss'; import ErrorContainer from './error/error-container'; import UserContainer from './user/user-container'; import ShowUserContainer from './user/show-user-container'; -export default class App extends Component { - constructor (props) { - super(props); - this.state = { drawerActive: false }; +const base = { + name: 'Unleash', + link: '/', +}; - this.toggleDrawerActive = () => { - this.setState({ drawerActive: !this.state.drawerActive }); +function replace (input, params) { + if (!params) { + return input; + } + Object.keys(params).forEach(key => { + input = input.replace(`:${key}`, params[key]); + }); + return input; +} + +export default class App extends Component { + static propTypes () { + return { + location: PropTypes.object.isRequired, + params: PropTypes.object.isRequired, + routes: PropTypes.array.isRequired, }; } + static contextTypes = { router: React.PropTypes.object, } - componentDidMount () { - document.title = `${this.getCurrentSection()} - Unleash Admin`; + componentWillReceiveProps (nextProps) { + if (this.props.location.pathname !== nextProps.location.pathname) { + clearTimeout(this.timer); + this.timer = setTimeout(() => { + window.requestAnimationFrame(() => { + document.querySelector('.mdl-layout__content').scrollTop = 0; + }); + + 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); + } } - getCurrentSection () { - const { routes } = this.props; - const lastRoute = routes[routes.length - 1]; - return lastRoute ? lastRoute.pageTitle : ''; + getSections () { + const { routes, params } = this.props; + const unique = {}; + let result = [base].concat(routes.splice(1).map((routeEntry) => ({ + name: replace(routeEntry.pageTitle, params), + link: replace(routeEntry.link || routeEntry.path, params), + }))).filter(entry => { + if (!unique[entry.link]) { + unique[entry.link] = true; + return true; + } + return false; + }); + + // mutate document.title: + document.title = result + .map(e => e.name) + .reverse() + .join(' - '); + + if (result.length > 2) { + result = result.splice(1); + } + + return result; } - onOverlayClick = () => this.setState({ drawerActive: false }); + getTitleWithLinks () { + const result = this.getSections(); + return ( + + {result.map((entry, index) => ( + + {entry.name} + {(index + 1) < result.length ? ' / ' : null} + ))} + + ); + } render () { - const createListItem = (path, caption) => + const createListItem = (path, caption, icon) => - {caption} + {icon && } {caption} ; return (
-
Unleash Admin / {this.getCurrentSection()}}> +
Github @@ -54,19 +116,15 @@ export default class App extends Component {
- {createListItem('/features', 'Feature toggles')} - {createListItem('/strategies', 'Strategies')} - {createListItem('/history', 'Event history')} - {createListItem('/archive', 'Archived toggles')} -
- {createListItem('/applications', 'Applications')} - {createListItem('/metrics', 'Client metrics')} - {createListItem('/client-strategies', 'Client strategies')} - {createListItem('/client-instances', 'Client instances')} + {createListItem('/features', 'Feature toggles', 'list')} + {createListItem('/strategies', 'Strategies', 'extension')} + {createListItem('/history', 'Event history', 'history')} + {createListItem('/archive', 'Archived toggles', 'archive')} + {createListItem('/applications', 'Applications', 'apps')}
- + {this.props.children} @@ -85,18 +143,6 @@ export default class App extends Component { {createListItem('/applications', 'Applications')} - {createListItem('/metrics', 'Client metrics')} - {createListItem('/client-strategies', 'Client strategies')} - {createListItem('/client-instances', 'Client instances')} - - - - - Help - Privacy & Terms - Questions - Answers - Contact Us @@ -119,29 +165,5 @@ export default class App extends Component {
); - - - - return ( -
- - - -
- - - - - -
- - {this.props.children} -
-
- -
-
-
- ); } }; diff --git a/frontend/src/component/application/application-edit-component.js b/frontend/src/component/application/application-edit-component.js index fa679fe2f2..ea9f1299be 100644 --- a/frontend/src/component/application/application-edit-component.js +++ b/frontend/src/component/application/application-edit-component.js @@ -1,57 +1,170 @@ -import React, { Component } from 'react'; +/* eslint react/no-multi-comp:off */ +import React, { Component, PureComponent } from 'react'; import { Link } from 'react-router'; -import { Grid, Cell } from 'react-mdl'; +import { + Grid, Cell, + List, ListItem, ListItemContent, + Textfield, Icon, ProgressBar, + Tabs, Tab, + Switch, +} from 'react-mdl'; +import { HeaderTitle, ExternalIconLink } from '../common'; -class ClientStrategies extends Component { +class StatefulTextfield extends Component { + constructor (props) { + super(props); + this.state = { value: props.value }; + this.setValue = function setValue (e) { + this.setState({ value: e.target.value }); + }.bind(this); + } + + render () { + return ( + ); + } +} + +class ClientApplications extends PureComponent { + constructor (props) { + super(props); + this.state = { activeTab: 0 }; + } componentDidMount () { this.props.fetchApplication(this.props.appName); } - render () { + render () { if (!this.props.application) { - return
Loading application info...
; + return ; } + const { + application, + storeApplicationMetaData, + } = this.props; const { appName, instances, strategies, seenToggles, - } = this.props.application; - + url, + description, + icon = 'apps', + color, + } = application; + + const content = this.state.activeTab === 0 ? ( + + +
Toggles
+
+ + {seenToggles.map(({ name, description, enabled, notFound }, i) => + (notFound ? + + + + {name} + + + : + + } subtitle={description}> + + + {name} + + + ) + )} + +
+ +
Implemented strategies
+
+ + {strategies.map(({ name, description, notFound }, i) => ( + notFound ? + + + + {name} + + + : + + + + {name} + + + + ))} + +
+ +
{instances.length} Instances connected
+
+ + {instances.map(({ instanceId, clientIp, lastSeen }, i) => ( + + {clientIp} last seen at {new Date(lastSeen).toLocaleString('nb-NO')} + }> + {instanceId} + + + ))} + +
+
) : ( + + +
Edit app meta data
+
+ + storeApplicationMetaData(appName, 'url', e.target.value)} />
+ storeApplicationMetaData(appName, 'description', e.target.value)} /> +
+ + storeApplicationMetaData(appName, 'icon', e.target.value)} /> + storeApplicationMetaData(appName, 'color', e.target.value)} /> + +
); + + return (
-
{appName}
- - - -
Instances
-
    - {instances.map(({ instanceId }, i) =>
  1. {instanceId}
  2. )} -
-
- -
Strategies
-
    - {/*strategies.map((name, i) =>
  1. {name}
  2. )*/} -
-
- -
Toggles
-
    - {seenToggles.map((name, i) =>
  1. - - {name} - -
  2. )} -
-
-
+ {appName}} subtitle={description} + actions={url && Visit site} + /> + + this.setState({ activeTab: tabId })} ripple> + Metrics + Edit + + + {content}
); } } -export default ClientStrategies; +export default ClientApplications; diff --git a/frontend/src/component/application/application-edit-container.js b/frontend/src/component/application/application-edit-container.js index fa76680372..b3b2d66989 100644 --- a/frontend/src/component/application/application-edit-container.js +++ b/frontend/src/component/application/application-edit-container.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import ApplicationEdit from './application-edit-component'; -import { fetchApplication } from '../../store/application/actions'; +import { fetchApplication, storeApplicationMetaData } from '../../store/application/actions'; const mapStateToProps = (state, props) => { let application = state.applications.getIn(['apps', props.appName]); @@ -12,6 +12,9 @@ const mapStateToProps = (state, props) => { }; }; -const Constainer = connect(mapStateToProps, { fetchApplication })(ApplicationEdit); +const Constainer = connect(mapStateToProps, { + fetchApplication, + storeApplicationMetaData, +})(ApplicationEdit); export default Constainer; diff --git a/frontend/src/component/application/application-list-component.js b/frontend/src/component/application/application-list-component.js index ccf44e7a48..2c472cb596 100644 --- a/frontend/src/component/application/application-list-component.js +++ b/frontend/src/component/application/application-list-component.js @@ -1,5 +1,6 @@ import React, { Component } from 'react'; -import { Link } from 'react-router'; +import { ProgressBar } from 'react-mdl'; +import { AppsLinkList, HeaderTitle } from '../common'; class ClientStrategies extends Component { @@ -13,15 +14,12 @@ class ClientStrategies extends Component { } = this.props; if (!applications) { - return
loading...
; + return ; } return (
- {applications.map(item => ( - - Link: {item.appName} - - ))} + +
); } diff --git a/frontend/src/component/archive/archive-list-component.jsx b/frontend/src/component/archive/archive-list-component.jsx index 07bc66d221..27ddec6ee3 100644 --- a/frontend/src/component/archive/archive-list-component.jsx +++ b/frontend/src/component/archive/archive-list-component.jsx @@ -1,5 +1,7 @@ import React, { Component } from 'react'; -import { DataTable, TableHeader, Chip, Switch, IconButton } from 'react-mdl'; +import { Link } from 'react-router'; +import { DataTable, TableHeader, IconButton, Icon } from 'react-mdl'; +import { HeaderTitle } from '../common'; class ArchiveList extends Component { componentDidMount () { @@ -8,20 +10,29 @@ class ArchiveList extends Component { render () { const { archive, revive } = this.props; + archive.forEach(e => { + e.reviveName = e.name; + }); return (
-
Toggle Archive
- - - ( - revive(name)} /> - )}>Revive - (v ? 'Yes' : '-')}>Enabled - Toggle name - Created - + + { + archive.length > 0 ? + + ( + revive(reviveName)} /> + )}>Revive + (v ? 'Yes' : '-')}>Enabled + Toggle name + Created + : +
+
+ No archived feature toggles, go see active toggles here +
+ }
); } diff --git a/frontend/src/component/client-instance/client-instance-component.js b/frontend/src/component/client-instance/client-instance-component.js index 83c6772691..bdc070f7be 100644 --- a/frontend/src/component/client-instance/client-instance-component.js +++ b/frontend/src/component/client-instance/client-instance-component.js @@ -22,14 +22,14 @@ class ClientStrategies extends Component { rows={source} selectable={false} > - - + + Instance ID Application name IP Created Last Seen - + ); } diff --git a/frontend/src/component/client-strategy/strategy-component.js b/frontend/src/component/client-strategy/strategy-component.js deleted file mode 100644 index fc65e23f55..0000000000 --- a/frontend/src/component/client-strategy/strategy-component.js +++ /dev/null @@ -1,35 +0,0 @@ -import React, { Component } from 'react'; -import { DataTable, TableHeader } from 'react-mdl'; - -class ClientStrategies extends Component { - - componentDidMount () { - this.props.fetchClientStrategies(); - } - - render () { - const source = this.props.clientStrategies - // temp hack for ignoring dumb data - .filter(item => item.strategies) - .map(item => ( - { - appName: item.appName, - strategies: item.strategies && item.strategies.join(', '), - }) - ); - - return ( - - Application name - Strategies - - ); - } -} - - -export default ClientStrategies; diff --git a/frontend/src/component/client-strategy/strategy-container.js b/frontend/src/component/client-strategy/strategy-container.js deleted file mode 100644 index e227c65a8a..0000000000 --- a/frontend/src/component/client-strategy/strategy-container.js +++ /dev/null @@ -1,9 +0,0 @@ -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/common/common.scss b/frontend/src/component/common/common.scss new file mode 100644 index 0000000000..3244d563de --- /dev/null +++ b/frontend/src/component/common/common.scss @@ -0,0 +1,5 @@ +.truncate { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} \ No newline at end of file diff --git a/frontend/src/component/common/index.js b/frontend/src/component/common/index.js new file mode 100644 index 0000000000..bd2c5e5082 --- /dev/null +++ b/frontend/src/component/common/index.js @@ -0,0 +1,119 @@ +const React = require('react'); +import styles from './common.scss'; + + +const { + List, ListItem, ListItemContent, + Button, Icon, + Switch, +} = require('react-mdl'); +const { Link } = require('react-router'); + +export const shorten = (str, len = 50) => (str && str.length > len ? `${str.substring(0, len)}...` : str); + +export const AppsLinkList = ({ apps }) => ( + + {apps.length > 0 && apps.map(({ appName, description = '-', icon = 'apps' }) => ( + + + + {appName} + + + + ))} + +); + +export const HeaderTitle = ({ title, actions, subtitle }) => ( +
+
+
{title}
+ {subtitle && {subtitle}} +
+ + {actions &&
{actions}
} +
+); + +export const FormButtons = ({ submitText = 'Create', onCancel }) => ( +
+ +   + +
+); + +export const SwitchWithLabel = ({ onChange, children, checked }) => ( + + + + + {children} + +); + +export const TogglesLinkList = ({ toggles }) => ( + + {toggles.length > 0 && toggles.map(({ name, description = '-', icon = 'toggle' }) => ( + + + + {name} + + + + ))} + +); + +export function getIcon (type) { + switch (type) { + case 'feature-updated': return 'autorenew'; + case 'feature-created': return 'add'; + case 'feature-deleted': return 'remove'; + case 'feature-archived': return 'archived'; + default: return 'star'; + } +}; + + +export const IconLink = ({ icon, children, ...props }) => ( + + + {children} + +); + +export const ExternalIconLink = ({ url, children }) => ( + + {children} + +); + +const badNumbers = [NaN, Infinity, -Infinity]; +export function calc (value, total, decimal) { + if (typeof value !== 'number' || + typeof total !== 'number' || + typeof decimal !== 'number') { + return null; + } + + if (total === 0) { + return 0; + } + + badNumbers.forEach((number) => { + if ([value, total, decimal].indexOf(number) > -1) { + return number; + } + }); + + return (value / total * 100).toFixed(decimal); +}; diff --git a/frontend/src/component/error/error-component.jsx b/frontend/src/component/error/error-component.jsx index 9cad4d9c25..1dc21f6597 100644 --- a/frontend/src/component/error/error-component.jsx +++ b/frontend/src/component/error/error-component.jsx @@ -1,6 +1,6 @@ import React, { PropTypes } from 'react'; -import { Snackbar } from 'react-mdl'; +import { Snackbar, Icon } from 'react-mdl'; class ErrorComponent extends React.Component { static propTypes () { @@ -18,13 +18,12 @@ class ErrorComponent extends React.Component { + timeout={10000} + > + {error} + ); } } diff --git a/frontend/src/component/feature/feature-list-item-component.jsx b/frontend/src/component/feature/feature-list-item-component.jsx index a8f1dcacab..40436c5242 100644 --- a/frontend/src/component/feature/feature-list-item-component.jsx +++ b/frontend/src/component/feature/feature-list-item-component.jsx @@ -1,8 +1,8 @@ import React, { PropTypes } from 'react'; import { Link } from 'react-router'; import { Chip, Switch, Icon, IconButton } from 'react-mdl'; -import percentLib from 'percent'; import Progress from './progress'; +import { shorten, calc } from '../common'; import style from './feature.scss'; @@ -20,8 +20,8 @@ const Feature = ({ const isStale = showLastHour ? metricsLastHour.isFallback : metricsLastMinute.isFallback; const percent = 1 * (showLastHour ? - percentLib.calc(metricsLastHour.yes, metricsLastHour.yes + metricsLastHour.no, 0) : - percentLib.calc(metricsLastMinute.yes, metricsLastMinute.yes + metricsLastMinute.no, 0) + calc(metricsLastHour.yes, metricsLastHour.yes + metricsLastHour.no, 0) : + calc(metricsLastMinute.yes, metricsLastMinute.yes + metricsLastMinute.no, 0) ); return (
  • @@ -29,19 +29,21 @@ const Feature = ({
    { isStale ? - : -
    - -
    + : +
    + +
    }
      - + onFeatureClick(feature)} checked={enabled} /> - - {name} {(description && description.substring(0, 100)) || ''} + + {name} {shorten(description, 30) || ''} @@ -52,7 +54,7 @@ const Feature = ({ - + onFeatureRemove(name)} className={style.iconListItem} /> diff --git a/frontend/src/component/feature/feature.scss b/frontend/src/component/feature/feature.scss index ee67a619da..36dbf6f306 100644 --- a/frontend/src/component/feature/feature.scss +++ b/frontend/src/component/feature/feature.scss @@ -58,4 +58,10 @@ .topListItem2 { flex: 2; -} \ No newline at end of file +} + +@media (max-width: 960px) { + .iconListItemChip { + display: none; + } +} diff --git a/frontend/src/component/feature/form-add-container.jsx b/frontend/src/component/feature/form-add-container.jsx index fa7f567c8b..2fe90c51d6 100644 --- a/frontend/src/component/feature/form-add-container.jsx +++ b/frontend/src/component/feature/form-add-container.jsx @@ -5,14 +5,23 @@ import { createMapper, createActions } from '../input-helpers'; import FormComponent from './form'; const ID = 'add-feature-toggle'; -const mapStateToProps = createMapper({ id: ID }); +const mapStateToProps = createMapper({ + id: ID, + getDefault () { + let name; + try { + [, name] = document.location.hash.match(/name=([a-z0-9-_]+)/i); + } catch (e) {} + return { name }; + }, +}); const prepare = (methods, dispatch) => { methods.onSubmit = (input) => ( (e) => { e.preventDefault(); createFeatureToggles(input)(dispatch) .then(() => methods.clear()) - .then(() => hashHistory.push('/features')); + .then(() => hashHistory.push(`/features/edit/${input.name}`)); } ); diff --git a/frontend/src/component/feature/form-edit-container.jsx b/frontend/src/component/feature/form-edit-container.jsx index 44a7b62fce..7f592804f8 100644 --- a/frontend/src/component/feature/form-edit-container.jsx +++ b/frontend/src/component/feature/form-edit-container.jsx @@ -27,14 +27,14 @@ const prepare = (methods, dispatch) => { // TODO: should add error handling requestUpdateFeatureToggle(input)(dispatch) .then(() => methods.clear()) - .then(() => window.history.back()); + .then(() => hashHistory.push(`/features/view/${input.name}`)); } ); methods.onCancel = (evt) => { evt.preventDefault(); methods.clear(); - hashHistory.push('/features'); + window.history.back(); }; methods.addStrategy = (v) => { diff --git a/frontend/src/component/feature/form/index.jsx b/frontend/src/component/feature/form/index.jsx index cbe25cc7c2..fcc159ee73 100644 --- a/frontend/src/component/feature/form/index.jsx +++ b/frontend/src/component/feature/form/index.jsx @@ -1,7 +1,9 @@ import React, { Component, PropTypes } from 'react'; -import { Textfield, Button, Switch } from 'react-mdl'; +import { Textfield, Switch } from 'react-mdl'; import StrategiesSection from './strategies-section-container'; +import { FormButtons, HeaderTitle } from '../../common'; + const trim = (value) => { if (value && value.trim) { return value.trim(); @@ -30,6 +32,7 @@ class AddFeatureToggleComponent extends Component { onSubmit, onCancel, editmode = false, + title, } = this.props; const { @@ -42,8 +45,10 @@ class AddFeatureToggleComponent extends Component { return (
    + {title && }
    setValue('name', trim(v.target.value))} />
    { - // todo is wrong way to get value? - setValue('enabled', (console.log(v.target) && v.target.value === 'on')); + onChange={() => { + setValue('enabled', !enabled); }}>Enabled -
    +

    - -   - + ); } diff --git a/frontend/src/component/feature/form/strategies-add.jsx b/frontend/src/component/feature/form/strategies-add.jsx index e5be2d4b1f..1f46b5d8fa 100644 --- a/frontend/src/component/feature/form/strategies-add.jsx +++ b/frontend/src/component/feature/form/strategies-add.jsx @@ -14,9 +14,8 @@ class AddStrategy extends React.Component { addStrategy = (strategyName) => { const selectedStrategy = this.props.strategies.find(s => s.name === strategyName); const parameters = {}; - const keys = Object.keys(selectedStrategy.parametersTemplate || {}); - keys.forEach(prop => { parameters[prop] = ''; }); + selectedStrategy.parameters.forEach(({ name }) => { parameters[name] = ''; }); this.props.addStrategy({ name: selectedStrategy.name, @@ -30,13 +29,19 @@ class AddStrategy extends React.Component { } render () { + const menuStyle = { + maxHeight: '300px', + overflowY: 'auto', + backgroundColor: 'rgb(247, 248, 255)', + }; return (
    - - this.setSort(e.target.getAttribute('data-target'))}> + + Add Strategy: - {this.props.strategies.map((s) => this.addStrategy(s.name)}>{s.name})} + {this.props.strategies.map((s) => + this.addStrategy(s.name)}>{s.name}) + }
    ); diff --git a/frontend/src/component/feature/form/strategies-list.jsx b/frontend/src/component/feature/form/strategies-list.jsx index 5357f878f6..1c4b04a576 100644 --- a/frontend/src/component/feature/form/strategies-list.jsx +++ b/frontend/src/component/feature/form/strategies-list.jsx @@ -22,16 +22,16 @@ class StrategiesList extends React.Component { return No strategies added; } - const blocks = configuredStrategies.map((strat, i) => ( + const blocks = configuredStrategies.map((strategy, i) => ( s.name === strat.name)} /> + strategyDefinition={strategies.find(s => s.name === strategy.name)} /> )); return ( -
    +
    {blocks}
    ); diff --git a/frontend/src/component/feature/form/strategies-section-container.jsx b/frontend/src/component/feature/form/strategies-section-container.jsx index 7f12216935..263f5cbee1 100644 --- a/frontend/src/component/feature/form/strategies-section-container.jsx +++ b/frontend/src/component/feature/form/strategies-section-container.jsx @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import StrategiesSection from './strategies-section'; -import { fetchStrategies } from '../../../store/strategy-actions'; +import { fetchStrategies } from '../../../store/strategy/actions'; export default connect((state) => ({ diff --git a/frontend/src/component/feature/form/strategies-section.jsx b/frontend/src/component/feature/form/strategies-section.jsx index 83ec3416da..ea9097f67f 100644 --- a/frontend/src/component/feature/form/strategies-section.jsx +++ b/frontend/src/component/feature/form/strategies-section.jsx @@ -1,10 +1,8 @@ import React, { PropTypes } from 'react'; +import { ProgressBar } from 'react-mdl'; import StrategiesList from './strategies-list'; import AddStrategy from './strategies-add'; - -const headerStyle = { - marginBottom: '10px', -}; +import { HeaderTitle } from '../../common'; class StrategiesSection extends React.Component { @@ -24,12 +22,12 @@ class StrategiesSection extends React.Component { render () { if (!this.props.strategies || this.props.strategies.length === 0) { - return Loding available strategies; + return ; } return (
    -
    Activation strategies
    + } />
    ); diff --git a/frontend/src/component/feature/form/strategy-configure.jsx b/frontend/src/component/feature/form/strategy-configure.jsx index 6ed77fb337..f8475e016e 100644 --- a/frontend/src/component/feature/form/strategy-configure.jsx +++ b/frontend/src/component/feature/form/strategy-configure.jsx @@ -1,6 +1,20 @@ import React, { PropTypes } from 'react'; -import { Textfield, Button } from 'react-mdl'; +import { + Textfield, Button, + Card, CardTitle, CardText, CardActions, CardMenu, + IconButton, Icon, +} from 'react-mdl'; +import { Link } from 'react-router'; +import StrategyInputPersentage from './strategy-input-persentage'; +import StrategyInputList from './strategy-input-list'; +const style = { + flex: '1', + minWidth: '300px', + maxWidth: '100%', + margin: '5px 20px 15px 0px', + background: '#f2f9fc', +}; class StrategyConfigure extends React.Component { static propTypes () { @@ -12,57 +26,144 @@ class StrategyConfigure extends React.Component { }; } + // shouldComponentUpdate (props, nextProps) { + // console.log({ props, nextProps }); + // } + handleConfigChange = (key, e) => { + this.setConfig(key, e.target.value); + }; + + setConfig = (key, value) => { const parameters = this.props.strategy.parameters || {}; - parameters[key] = e.target.value; + parameters[key] = value; const updatedStrategy = Object.assign({}, this.props.strategy, { parameters }); this.props.updateStrategy(updatedStrategy); - }; + } handleRemove = (evt) => { evt.preventDefault(); this.props.removeStrategy(); } - renderInputFields (strategyDefinition) { - if (strategyDefinition.parametersTemplate) { - return Object.keys(strategyDefinition.parametersTemplate).map(field => ( - - )); + renderInputFields ({ parameters }) { + if (parameters && parameters.length > 0) { + return parameters.map(({ name, type, description, required }) => { + let value = this.props.strategy.parameters[name]; + if (type === 'percentage') { + if (value == null || (typeof value === 'string' && value === '')) { + value = 50; // default value + } + return ( +
    + + {description &&

    {description}

    } +
    + ); + } else if (type === 'list') { + let list = []; + if (typeof value === 'string') { + list = value + .trim() + .split(',') + .filter(Boolean); + } + return ( +
    + + {description &&

    {description}

    } +
    + ); + } else if (type === 'number') { + return ( +
    + + {description &&

    {description}

    } +
    + ); + } else { + return ( +
    + + {description &&

    {description}

    } +
    + ); + } + }); } + return null; } render () { if (!this.props.strategyDefinition) { + const { name } = this.props.strategy; return ( -
    -
    Strategy "{this.props.strategy.name}" deleted
    -
    + + "{name}" deleted? + + The strategy "{name}" does not exist on this server. + Want to create it now? + + + + + + ); } - const inputFields = this.renderInputFields(this.props.strategyDefinition) || []; + const inputFields = this.renderInputFields(this.props.strategyDefinition); + + const { name } = this.props.strategy; return ( -
    -
    - {this.props.strategy.name} - (remove) -
    - {this.props.strategyDefinition.description} -
    - {inputFields} -
    -
    + + +  { name } + + + {this.props.strategyDefinition.description} + + { + inputFields && + {inputFields} + + } + + + + + + + + ); } } diff --git a/frontend/src/component/feature/form/strategy-input-list.jsx b/frontend/src/component/feature/form/strategy-input-list.jsx new file mode 100644 index 0000000000..71f611b61c --- /dev/null +++ b/frontend/src/component/feature/form/strategy-input-list.jsx @@ -0,0 +1,80 @@ +import React, { Component, PropTypes } from 'react'; +import { + Textfield, + IconButton, + Chip, +} from 'react-mdl'; + +export default class InputList extends Component { + + static propTypes = { + name: PropTypes.string.isRequired, + list: PropTypes.array.isRequired, + setConfig: PropTypes.func.isRequired, + } + + onBlur = (e) => { + this.setValue(e); + window.removeEventListener('keydown', this.onKeyHandler, false); + } + + onFocus = (e) => { + e.preventDefault(); + e.stopPropagation(); + window.addEventListener('keydown', this.onKeyHandler, false); + } + + onKeyHandler = (e) => { + if (e.key === 'Enter') { + this.setValue(); + e.preventDefault(); + e.stopPropagation(); + } + } + + setValue = (e) => { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + + const { name, list, setConfig } = this.props; + const inputValue = document.querySelector(`[name="${name}_input"]`); + if (inputValue && inputValue.value) { + list.push(inputValue.value); + inputValue.value = ''; + setConfig(name, list.join(',')); + } + } + + onClose (index) { + const { name, list, setConfig } = this.props; + list[index] = null; + setConfig(name, list.length === 1 ? '' : list.filter(Boolean).join(',')); + } + + render () { + const { name, list } = this.props; + return (
    +

    {name}

    + {list.map((entryValue, index) => ( + this.onClose(index)}>{entryValue} + ))} + +
    + + +
    + +
    ); + } +} diff --git a/frontend/src/component/feature/form/strategy-input-persentage.jsx b/frontend/src/component/feature/form/strategy-input-persentage.jsx new file mode 100644 index 0000000000..563d949de1 --- /dev/null +++ b/frontend/src/component/feature/form/strategy-input-persentage.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Slider } from 'react-mdl'; + +const labelStyle = { + margin: '20px 0', + textAlign: 'center', + color: '#3f51b5', + fontSize: '12px', +}; + +export default ({ name, value, onChange }) => ( +
    +
    {name}: {value}%
    + +
    +); diff --git a/frontend/src/component/feature/list-component.jsx b/frontend/src/component/feature/list-component.jsx index 08852e3938..bed90978d2 100644 --- a/frontend/src/component/feature/list-component.jsx +++ b/frontend/src/component/feature/list-component.jsx @@ -52,7 +52,6 @@ export default class FeatureListComponent extends React.PureComponent { return (
    - this.toggleMetrics()} className={styles.topListItem0}> { settings.showLastHour && @@ -68,8 +67,7 @@ export default class FeatureListComponent extends React.PureComponent { } { '1 minute' } - - +
    - +
    - - - + -
      - {features.map((feature, i) => - - )} + {features.map((feature, i) => + + )}

    diff --git a/frontend/src/component/feature/metric-component.jsx b/frontend/src/component/feature/metric-component.jsx new file mode 100644 index 0000000000..a47e889e1e --- /dev/null +++ b/frontend/src/component/feature/metric-component.jsx @@ -0,0 +1,84 @@ +import React, { PropTypes } from 'react'; +import { Grid, Cell, Icon } from 'react-mdl'; +import Progress from './progress'; +import { AppsLinkList, SwitchWithLabel, calc } from '../common'; + + +export default class MetricComponent extends React.Component { + static propTypes () { + return { + metrics: PropTypes.object.isRequired, + featureToggle: PropTypes.object.isRequired, + toggleFeature: PropTypes.func.isRequired, + fetchSeenApps: PropTypes.func.isRequired, + fetchFeatureMetrics: PropTypes.func.isRequired, + }; + } + + componentWillMount () { + this.props.fetchSeenApps(); + this.props.fetchFeatureMetrics(); + this.timer = setInterval(() => { + this.props.fetchFeatureMetrics(); + }, 5000); + } + + componentWillUnmount () { + clearInterval(this.timer); + } + + render () { + const { metrics = {}, featureToggle, toggleFeature } = this.props; + const { + lastHour = { yes: 0, no: 0, isFallback: true }, + lastMinute = { yes: 0, no: 0, isFallback: true }, + seenApps = [], + } = metrics; + + const lastHourPercent = 1 * calc(lastHour.yes, lastHour.yes + lastHour.no, 0); + const lastMinutePercent = 1 * calc(lastMinute.yes, lastMinute.yes + lastMinute.no, 0); + + return (
    + toggleFeature(featureToggle)}>Toggle {featureToggle.name} +
    + + + { + lastMinute.isFallback ? + : +
    + +
    + } +

    Last minute
    Yes {lastMinute.yes}, No: {lastMinute.no}

    +
    + + { + lastHour.isFallback ? + : +
    + +
    + } +

    Last hour
    Yes {lastHour.yes}, No: {lastHour.no}

    +
    + + {seenApps.length > 0 ? + (
    Seen in applications:
    ) : +
    + +
    Not used in a app in the last hour. + This might be due to your client implementation is not reporting usage.
    +
    + } + +
    +
    +
    ); + } +} diff --git a/frontend/src/component/feature/metric-container.jsx b/frontend/src/component/feature/metric-container.jsx new file mode 100644 index 0000000000..89bfff15bc --- /dev/null +++ b/frontend/src/component/feature/metric-container.jsx @@ -0,0 +1,32 @@ + +import { connect } from 'react-redux'; + +import { fetchFeatureMetrics, fetchSeenApps } from '../../store/feature-metrics-actions'; +import { toggleFeature } from '../../store/feature-actions'; + +import MatricComponent from './metric-component'; + +function getMetricsForToggle (state, toggleName) { + if (!toggleName) { + return; + } + const result = {}; + + if (state.featureMetrics.hasIn(['seenApps', toggleName])) { + result.seenApps = state.featureMetrics.getIn(['seenApps', toggleName]); + } + if (state.featureMetrics.hasIn(['lastHour', toggleName])) { + result.lastHour = state.featureMetrics.getIn(['lastHour', toggleName]); + result.lastMinute = state.featureMetrics.getIn(['lastMinute', toggleName]); + } + return result; +} + + +export default connect((state, props) => ({ + metrics: getMetricsForToggle(state, props.featureToggleName), +}), { + fetchFeatureMetrics, + toggleFeature, + fetchSeenApps, +})(MatricComponent); diff --git a/frontend/src/component/feature/progress-styles.scss b/frontend/src/component/feature/progress-styles.scss index c036577277..b7a00a2a1b 100644 --- a/frontend/src/component/feature/progress-styles.scss +++ b/frontend/src/component/feature/progress-styles.scss @@ -14,4 +14,4 @@ line-height: 25px; dominant-baseline: middle; text-anchor: middle; -} \ No newline at end of file +} diff --git a/frontend/src/component/feature/progress.jsx b/frontend/src/component/feature/progress.jsx index 59386daeb3..c17d970b79 100644 --- a/frontend/src/component/feature/progress.jsx +++ b/frontend/src/component/feature/progress.jsx @@ -7,13 +7,14 @@ class Progress extends Component { this.state = { percentage: props.initialAnimation ? 0 : props.percentage, + percentageText: props.initialAnimation ? 0 : props.percentage, }; } componentDidMount () { if (this.props.initialAnimation) { this.initialTimeout = setTimeout(() => { - this.requestAnimationFrame = window.requestAnimationFrame(() => { + this.rafTimerInit = window.requestAnimationFrame(() => { this.setState({ percentage: this.props.percentage, }); @@ -23,16 +24,65 @@ class Progress extends Component { } componentWillReceiveProps ({ percentage }) { - this.setState({ percentage }); + if (this.state.percentage !== percentage) { + const nextState = { percentage }; + if (this.props.animatePercentageText) { + this.animateTo(percentage, this.getTarget(percentage)); + } else { + nextState.percentageText = percentage; + } + this.setState(nextState); + } } + getTarget (target) { + const start = this.state.percentageText; + const TOTAL_ANIMATION_TIME = 5000; + const diff = start > target ? -(start - target) : target - start; + const perCycle = TOTAL_ANIMATION_TIME / diff; + const cyclesCounter = Math.round(Math.abs(TOTAL_ANIMATION_TIME / perCycle)); + const perCycleTime = Math.round(Math.abs(perCycle)); + + return { + start, + target, + cyclesCounter, + perCycleTime, + increment: diff / cyclesCounter, + }; + } + + animateTo (percentage, targetState) { + cancelAnimationFrame(this.rafCounterTimer); + clearTimeout(this.nextTimer); + + const current = this.state.percentageText; + + targetState.cyclesCounter --; + if (targetState.cyclesCounter <= 0) { + this.setState({ percentageText: targetState.target }); + return; + } + + const next = Math.round(current + targetState.increment); + this.rafCounterTimer = requestAnimationFrame(() => { + this.setState({ percentageText: next }); + this.nextTimer = setTimeout(() => { + this.animateTo(next, targetState); + }, targetState.perCycleTime); + }); + } + + componentWillUnmount () { clearTimeout(this.initialTimeout); - window.cancelAnimationFrame(this.requestAnimationFrame); + clearTimeout(this.nextTimer); + window.cancelAnimationFrame(this.rafTimerInit); + window.cancelAnimationFrame(this.rafCounterTimer); } render () { - const { strokeWidth, percentage } = this.props; + const { strokeWidth } = this.props; const radius = (50 - strokeWidth / 2); const pathDescription = ` M 50,50 m 0,-${radius} @@ -66,7 +116,7 @@ class Progress extends Component { className={styles.text} x={50} y={50} - >{percentage}% + >{this.state.percentageText}% ); } } @@ -75,11 +125,13 @@ Progress.propTypes = { percentage: PropTypes.number.isRequired, strokeWidth: PropTypes.number, initialAnimation: PropTypes.bool, + animatePercentageText: PropTypes.bool, textForPercentage: PropTypes.func, }; Progress.defaultProps = { strokeWidth: 8, + animatePercentageText: false, initialAnimation: false, }; diff --git a/frontend/src/component/feature/view-component.jsx b/frontend/src/component/feature/view-component.jsx new file mode 100644 index 0000000000..0ed75f4cd1 --- /dev/null +++ b/frontend/src/component/feature/view-component.jsx @@ -0,0 +1,97 @@ +import React, { PropTypes } from 'react'; +import { Tabs, Tab, ProgressBar } from 'react-mdl'; +import { hashHistory, Link } from 'react-router'; + +import HistoryComponent from '../history/history-list-toggle-container'; +import MetricComponent from './metric-container'; +import EditFeatureToggle from './form-edit-container.jsx'; + +const TABS = { + view: 0, + edit: 1, + history: 2, +}; + +export default class ViewFeatureToggleComponent extends React.Component { + + constructor (props) { + super(props); + } + + static propTypes () { + return { + activeTab: PropTypes.string.isRequired, + featureToggleName: PropTypes.string.isRequired, + features: PropTypes.array.isRequired, + fetchFeatureToggles: PropTypes.array.isRequired, + featureToggle: PropTypes.object.isRequired, + }; + } + + componentWillMount () { + if (this.props.features.length === 0) { + this.props.fetchFeatureToggles(); + } + } + + getTabContent (activeTab) { + const { + featureToggle, + featureToggleName, + } = this.props; + + if (TABS[activeTab] === TABS.history) { + return ; + } else if (TABS[activeTab] === TABS.edit) { + return ; + } else { + return ; + } + } + + goToTab (tabName, featureToggleName) { + hashHistory.push(`/features/${tabName}/${featureToggleName}`); + } + + render () { + const { + featureToggle, + features, + activeTab, + featureToggleName, + } = this.props; + + if (!featureToggle) { + if (features.length === 0 ) { + return ; + } + return ( + + Could not find the toggle + {featureToggleName} + + ); + } + + const activeTabId = TABS[this.props.activeTab] ? TABS[this.props.activeTab] : TABS.view; + const tabContent = this.getTabContent(activeTab); + + return ( +
    +

    {featureToggle.name} {featureToggle.enabled ? 'is enabled' : 'is disabled'} + + Created {(new Date(featureToggle.createdAt)).toLocaleString('nb-NO')} + +

    +
    {featureToggle.description}
    + + this.goToTab('view', featureToggleName)}>Metrics + this.goToTab('edit', featureToggleName)}>Edit + this.goToTab('history', featureToggleName)}>History + + + {tabContent} +
    + ); + } +} diff --git a/frontend/src/component/feature/view-container.jsx b/frontend/src/component/feature/view-container.jsx new file mode 100644 index 0000000000..47bedf154a --- /dev/null +++ b/frontend/src/component/feature/view-container.jsx @@ -0,0 +1,14 @@ + +import { connect } from 'react-redux'; + +import { fetchFeatureToggles } from '../../store/feature-actions'; + +import ViewToggleComponent from './view-component'; + +export default connect((state, props) => ({ + features: state.features.toJS(), + featureToggle: state.features.toJS().find(toggle => toggle.name === props.featureToggleName), + activeTab: props.activeTab, +}), { + fetchFeatureToggles, +})(ViewToggleComponent); diff --git a/frontend/src/component/feature/view-edit-container.jsx b/frontend/src/component/feature/view-edit-container.jsx deleted file mode 100644 index f08f9af9db..0000000000 --- a/frontend/src/component/feature/view-edit-container.jsx +++ /dev/null @@ -1,148 +0,0 @@ -import React, { PropTypes } from 'react'; -import { Grid, Cell, Icon, Switch } from 'react-mdl'; -import { Link } from 'react-router'; - -import percentLib from 'percent'; -import Progress from './progress'; - -import { connect } from 'react-redux'; -import EditFeatureToggle from './form-edit-container.jsx'; -import { fetchFeatureToggles, toggleFeature } from '../../store/feature-actions'; -import { fetchFeatureMetrics, fetchSeenApps } from '../../store/feature-metrics-actions'; - -class EditFeatureToggleWrapper extends React.Component { - - static propTypes () { - return { - featureToggleName: PropTypes.string.isRequired, - features: PropTypes.array.isRequired, - fetchFeatureToggles: PropTypes.array.isRequired, - }; - } - - componentWillMount () { - if (this.props.features.length === 0) { - this.props.fetchFeatureToggles(); - } - this.props.fetchSeenApps(); - this.props.fetchFeatureMetrics(); - this.timer = setInterval(() => { - this.props.fetchSeenApps(); - this.props.fetchFeatureMetrics(); - }, 5000); - } - - componentWillUnmount () { - clearInterval(this.timer); - } - - render () { - const { - toggleFeature, - features, - featureToggleName, - metrics = {}, - } = this.props; - - const { - lastHour = { yes: 0, no: 0, isFallback: true }, - lastMinute = { yes: 0, no: 0, isFallback: true }, - seenApps = [], - } = metrics; - - const lastHourPercent = 1 * percentLib.calc(lastHour.yes, lastHour.yes + lastHour.no, 0); - const lastMinutePercent = 1 * percentLib.calc(lastMinute.yes, lastMinute.yes + lastMinute.no, 0); - - const featureToggle = features.find(toggle => toggle.name === featureToggleName); - - if (!featureToggle) { - if (features.length === 0 ) { - return Loading; - } - return Could not find {this.props.featureToggleName}; - } - - return ( -
    -

    {featureToggle.name} {featureToggle.enabled ? 'is enabled' : 'is disabled'}

    -
    -
    - toggleFeature(featureToggle)} checked={featureToggle.enabled}> - Toggle {featureToggle.name} - -
    -
    - - - { - lastMinute.isFallback ? - : -
    - -
    - } -

    Last minute
    Yes {lastMinute.yes}, No: {lastMinute.no}

    -
    - - { - lastHour.isFallback ? - : -
    - -
    - } -

    Last hour
    Yes {lastHour.yes}, No: {lastHour.no}

    -
    - - {seenApps.length > 0 ? - (
    Seen in applications:
    ) : -
    - -
    Not used in a app in the last hour. This might be due to your client implementation is not reporting usage.
    -
    - } - {seenApps.length > 0 && seenApps.map((appName) => ( - - {appName} - - ))} -

    add instances count?

    -
    - -

    add history

    -
    -
    -
    -

    Edit

    - -
    - ); - } -} - -function getMetricsForToggle (state, toggleName) { - if (!toggleName) { - return; - } - const result = {}; - - if (state.featureMetrics.hasIn(['seenApps', toggleName])) { - result.seenApps = state.featureMetrics.getIn(['seenApps', toggleName]); - } - if (state.featureMetrics.hasIn(['lastHour', toggleName])) { - result.lastHour = state.featureMetrics.getIn(['lastHour', toggleName]); - result.lastMinute = state.featureMetrics.getIn(['lastMinute', toggleName]); - } - return result; -} - - -export default connect((state, props) => ({ - features: state.features.toJS(), - metrics: getMetricsForToggle(state, props.featureToggleName), -}), { - fetchFeatureMetrics, - fetchFeatureToggles, - toggleFeature, - fetchSeenApps, -})(EditFeatureToggleWrapper); diff --git a/frontend/src/component/history/history-component.jsx b/frontend/src/component/history/history-component.jsx index 153fa140e2..485654e1bc 100644 --- a/frontend/src/component/history/history-component.jsx +++ b/frontend/src/component/history/history-component.jsx @@ -18,10 +18,7 @@ class History extends PureComponent { } return ( -
    -
    Last 100 changes
    - -
    + ); } } diff --git a/frontend/src/component/history/history-item-diff.jsx b/frontend/src/component/history/history-item-diff.jsx index e98bb530f8..78284cb624 100644 --- a/frontend/src/component/history/history-item-diff.jsx +++ b/frontend/src/component/history/history-item-diff.jsx @@ -1,5 +1,4 @@ import React, { PropTypes, PureComponent } from 'react'; -import { Icon } from 'react-mdl'; import style from './history.scss'; @@ -10,35 +9,25 @@ const DIFF_PREFIXES = { N: '+', }; -const SPADEN_CLASS = { +const KLASSES = { A: style.blue, // array edited E: style.blue, // edited D: style.negative, // deleted N: style.positive, // added }; -function getIcon (type) { - switch (type) { - case 'feature-updated': return 'autorenew'; - case 'feature-created': return 'add'; - case 'feature-deleted': return 'remove'; - case 'feature-archived': return 'archived'; - default: return 'star'; - } -} - function buildItemDiff (diff, key) { let change; if (diff.lhs !== undefined) { change = (
    -
    - {key}: {JSON.stringify(diff.lhs)}
    +
    - {key}: {JSON.stringify(diff.lhs)}
    ); } else if (diff.rhs !== undefined) { change = (
    -
    + {key}: {JSON.stringify(diff.rhs)}
    +
    + {key}: {JSON.stringify(diff.rhs)}
    ); } @@ -55,12 +44,12 @@ function buildDiff (diff, idx) { } else if (diff.lhs !== undefined && diff.rhs !== undefined) { change = (
    -
    - {key}: {JSON.stringify(diff.lhs)}
    -
    + {key}: {JSON.stringify(diff.rhs)}
    +
    - {key}: {JSON.stringify(diff.lhs)}
    +
    + {key}: {JSON.stringify(diff.rhs)}
    ); } else { - const spadenClass = SPADEN_CLASS[diff.kind]; + const spadenClass = KLASSES[diff.kind]; const prefix = DIFF_PREFIXES[diff.kind]; change = (
    {prefix} {key}: {JSON.stringify(diff.rhs || diff.item)}
    ); @@ -77,50 +66,20 @@ class HistoryItem extends PureComponent { }; } - renderEventDiff (logEntry) { + render () { + const entry = this.props.entry; let changes; - if (logEntry.diffs) { - changes = logEntry.diffs.map(buildDiff); + if (entry.diffs) { + changes = entry.diffs.map(buildDiff); } else { // Just show the data if there is no diff yet. - changes =
    {JSON.stringify(logEntry.data, null, 2)}
    ; + changes =
    {JSON.stringify(entry.data, null, 2)}
    ; } - return {changes.length === 0 ? '(no changes)' : changes}; - } - - render () { - const { - createdBy, - id, - type, - } = this.props.entry; - - const createdAt = (new Date(this.props.entry.createdAt)).toLocaleString('nb-NO'); - const icon = getIcon(type); - - const data = this.renderEventDiff(this.props.entry); - - return ( -
    -
    -
    Id:
    -
    {id}
    -
    Type:
    -
    - - {type} -
    -
    Timestamp:
    -
    {createdAt}
    -
    Username:
    -
    {createdBy}
    -
    Diff
    -
    {data}
    -
    -
    - ); + return (
    +            {changes.length === 0 ? '(no changes)' : changes}
    +        
    ); } } diff --git a/frontend/src/component/history/history-list-component.jsx b/frontend/src/component/history/history-list-component.jsx index d13ecfc3ea..a937c8ffa8 100644 --- a/frontend/src/component/history/history-list-component.jsx +++ b/frontend/src/component/history/history-list-component.jsx @@ -1,7 +1,8 @@ import React, { Component } from 'react'; import HistoryItemDiff from './history-item-diff'; import HistoryItemJson from './history-item-json'; -import { Switch } from 'react-mdl'; +import { Table, TableHeader } from 'react-mdl'; +import { HeaderTitle, SwitchWithLabel } from '../common'; import style from './history.scss'; @@ -23,13 +24,28 @@ class HistoryList extends Component { if (showData) { entries = history.map((entry) => ); } else { - entries = history.map((entry) => ); + entries = ( Object.assign({ + diff: (), + }, entry)) + } + style={{ width: '100%' }} + > + Type + User + Diff + (new Date(v)).toLocaleString('nb-NO')}>Time +
    ); } return (
    - Show full events - {entries} + Show full events + }/> + {entries}
    ); } diff --git a/frontend/src/component/history/history-list-container.jsx b/frontend/src/component/history/history-list-container.jsx index df8a725a80..b3055e52e3 100644 --- a/frontend/src/component/history/history-list-container.jsx +++ b/frontend/src/component/history/history-list-container.jsx @@ -1,5 +1,5 @@ import { connect } from 'react-redux'; -import HistoryListComponent from './history-list-component'; +import HistoryListToggleComponent from './history-list-component'; import { updateSettingForGroup } from '../../store/settings/actions'; const mapStateToProps = (state) => { @@ -12,6 +12,6 @@ const mapStateToProps = (state) => { const HistoryListContainer = connect(mapStateToProps, { updateSetting: updateSettingForGroup('history'), -})(HistoryListComponent); +})(HistoryListToggleComponent); export default HistoryListContainer; diff --git a/frontend/src/component/history/history-list-toggle-component.jsx b/frontend/src/component/history/history-list-toggle-component.jsx index aba456efb2..46580fd22b 100644 --- a/frontend/src/component/history/history-list-toggle-component.jsx +++ b/frontend/src/component/history/history-list-toggle-component.jsx @@ -1,17 +1,9 @@ import React, { Component, PropTypes } from 'react'; import ListComponent from './history-list-container'; -import { fetchHistoryForToggle } from '../../data/history-api'; +import { Link } from 'react-router'; class HistoryListToggle extends Component { - constructor (props) { - super(props); - this.state = { - fetching: true, - history: undefined, - }; - } - static propTypes () { return { toggleName: PropTypes.string.isRequired, @@ -19,21 +11,24 @@ class HistoryListToggle extends Component { } componentDidMount () { - fetchHistoryForToggle(this.props.toggleName) - .then((res) => this.setState({ history: res, fetching: false })); + this.props.fetchHistoryForToggle(this.props.toggleName); } render () { - if (this.state.fetching) { + if (!this.props.history || this.props.history.length === 0) { return fetching..; } - + const { history, toggleName } = this.props; return ( -
    -
    Showing history for toggle: {this.props.toggleName}
    - -
    + Showing history for toggle: + {toggleName} + + }/> ); } } + export default HistoryListToggle; diff --git a/frontend/src/component/history/history-list-toggle-container.jsx b/frontend/src/component/history/history-list-toggle-container.jsx new file mode 100644 index 0000000000..e44c4d4cba --- /dev/null +++ b/frontend/src/component/history/history-list-toggle-container.jsx @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import HistoryListToggleComponent from './history-list-toggle-component'; +import { fetchHistoryForToggle } from '../../store/history-actions'; + +function getHistoryFromToggle (state, toggleName) { + if (!toggleName) { + return []; + } + + if (state.history.hasIn(['toggles', toggleName])) { + return state.history.getIn(['toggles', toggleName]).toArray(); + } + + return []; +} + +const mapStateToProps = (state, props) => ({ + history: getHistoryFromToggle(state, props.toggleName), +}); + +const HistoryListToggleContainer = connect(mapStateToProps, { + fetchHistoryForToggle, +})(HistoryListToggleComponent); + +export default HistoryListToggleContainer; diff --git a/frontend/src/component/input-helpers.js b/frontend/src/component/input-helpers.js index 8afefcec4e..c0b24790bc 100644 --- a/frontend/src/component/input-helpers.js +++ b/frontend/src/component/input-helpers.js @@ -57,8 +57,8 @@ export function createActions ({ id, prepare = (v) => v }) { dispatch(createPop({ id: getId(id, ownProps), key, index })); }, - updateInList (key, index, newValue) { - dispatch(createUp({ id: getId(id, ownProps), key, index, newValue })); + updateInList (key, index, newValue, merge = false) { + dispatch(createUp({ id: getId(id, ownProps), key, index, newValue, merge })); }, incValue (key) { diff --git a/frontend/src/component/metrics/metrics-component.js b/frontend/src/component/metrics/metrics-component.js deleted file mode 100644 index e2071220a5..0000000000 --- a/frontend/src/component/metrics/metrics-component.js +++ /dev/null @@ -1,35 +0,0 @@ -import React, { Component } from 'react'; -import { DataTable, TableHeader } from 'react-mdl'; - -class Metrics extends Component { - - componentDidMount () { - this.props.fetchMetrics(); - } - - render () { - const { globalCount, clientList } = this.props; - - return ( -
    -

    {`Total of ${globalCount} toggles`}

    - - Instance - Application name - (v.toString()) - }>Last seen - Counted - - -
    - ); - } -} - - -export default Metrics; diff --git a/frontend/src/component/metrics/metrics-container.js b/frontend/src/component/metrics/metrics-container.js deleted file mode 100644 index e4ab36af79..0000000000 --- a/frontend/src/component/metrics/metrics-container.js +++ /dev/null @@ -1,39 +0,0 @@ -import { connect } from 'react-redux'; -import Metrics from './metrics-component'; -import { fetchMetrics } from '../../store/metrics-actions'; - -const mapStateToProps = (state) => { - const globalCount = state.metrics.get('globalCount'); - const apps = state.metrics.get('apps').toArray(); - const clients = state.metrics.get('clients').toJS(); - - const clientList = Object - .keys(clients) - .map((k) => { - const client = clients[k]; - return { - name: k, - appName: client.appName, - count: client.count, - ping: new Date(client.ping), - }; - }) - .sort((a, b) => (a.ping > b.ping ? -1 : 1)); - - - /* - Possible stuff to ask/answer: - * toggles in use but not in unleash-server - * nr of toggles using fallbackValue - * strategies implemented but not used - */ - return { - globalCount, - apps, - clientList, - }; -}; - -const MetricsContainer = connect(mapStateToProps, { fetchMetrics })(Metrics); - -export default MetricsContainer; diff --git a/frontend/src/component/strategies/add-container.js b/frontend/src/component/strategies/add-container.js index 1ff835c1b8..9b82ee8e7f 100644 --- a/frontend/src/component/strategies/add-container.js +++ b/frontend/src/component/strategies/add-container.js @@ -1,9 +1,9 @@ import { connect } from 'react-redux'; import { createMapper, createActions } from '../input-helpers'; -import { createStrategy } from '../../store/strategy-actions'; +import { createStrategy } from '../../store/strategy/actions'; -import AddStrategy, { PARAM_PREFIX } from './add-strategy'; +import AddStrategy from './add-strategy'; const ID = 'add-strategy'; @@ -11,16 +11,26 @@ const prepare = (methods, dispatch) => { methods.onSubmit = (input) => ( (e) => { e.preventDefault(); + // clean + const parameters = (input.parameters || []) + .filter((name) => !!name) + .map(({ + name, + type = 'string', + description = '', + required = false, + }) => ({ + name, + type, + description, + required, + })); - const parametersTemplate = {}; - Object.keys(input).forEach(key => { - if (key.startsWith(PARAM_PREFIX)) { - parametersTemplate[input[key]] = 'string'; - } - }); - input.parametersTemplate = parametersTemplate; - - createStrategy(input)(dispatch) + createStrategy({ + name: input.name, + description: input.description, + parameters, + })(dispatch) .then(() => methods.clear()) // somewhat quickfix / hacky to go back.. .then(() => window.history.back()); @@ -43,4 +53,13 @@ const actions = createActions({ prepare, }); -export default connect(createMapper({ id: ID }), actions)(AddStrategy); +export default connect(createMapper({ + id: ID, + getDefault () { + let name; + try { + [, name] = document.location.hash.match(/name=([a-z0-9-_]+)/i); + } catch (e) {} + return { name }; + }, +}), actions)(AddStrategy); diff --git a/frontend/src/component/strategies/add-strategy.jsx b/frontend/src/component/strategies/add-strategy.jsx index f81d4695bf..a3ec16f941 100644 --- a/frontend/src/component/strategies/add-strategy.jsx +++ b/frontend/src/component/strategies/add-strategy.jsx @@ -1,6 +1,8 @@ -import React, { PropTypes } from 'react'; +import React, { PropTypes, Component } from 'react'; + +import { Textfield, IconButton, Menu, MenuItem, Checkbox } from 'react-mdl'; +import { FormButtons } from '../common'; -import { Textfield, Button, IconButton } from 'react-mdl'; const trim = (value) => { if (value && value.trim) { @@ -13,71 +15,161 @@ const trim = (value) => { function gerArrayWithEntries (num) { return Array.from(Array(num)); } -export const PARAM_PREFIX = 'param_'; -const genParams = (input, num = 0, setValue) => (
    {gerArrayWithEntries(num).map((v, i) => { - const key = `${PARAM_PREFIX}${i + 1}`; - return ( +const Parameter = ({ set, input = {}, index }) => ( +
    setValue(key, target.value)} - value={input[key]} /> - ); -})}
    ); - -const AddStrategy = ({ - input, - setValue, - incValue, - // clear, - onCancel, - onSubmit, -}) => ( -
    -
    - setValue('name', trim(target.value))} - value={input.name} - /> -
    - setValue('description', target.value)} - value={input.description} - /> -
    - -
    - {genParams(input, input._params, setValue)} - { - e.preventDefault(); - incValue('_params'); - }}/> -
    - -
    -
    - -
    - -   - -
    -
    + style={{ width: '50%' }} + floatingLabel + label={`Parameter name ${index + 1}`} + onChange={({ target }) => set({ name: target.value }, true)} + value={input.name} /> +
    + + {input.type || 'string'} + evt.preventDefault()} /> + + + set({ type: 'string' })}>string + set({ type: 'percentage' })}>percentage + set({ type: 'list' })}>list + set({ type: 'number' })}>number + +
    + set({ description: target.value })} + value={input.description} + /> + set({ required: !input.required })} + ripple + defaultChecked + /> +
    ); -AddStrategy.propTypes = { - input: PropTypes.object, - setValue: PropTypes.func, - incValue: PropTypes.func, - clear: PropTypes.func, - onCancel: PropTypes.func, - onSubmit: PropTypes.func, -}; +const EditHeader = () => ( +
    +

    Edit strategy

    +

    + Be carefull! Changing a strategy definition might also require changes to the + implementation in the clients. +

    +
    +); + +const CreateHeader = () => ( +
    +

    Create a new Strategy definition

    +
    +); + + +const Parameters = ({ input = [], count = 0, updateInList }) => ( +
    { + gerArrayWithEntries(count) + .map((v, i) => updateInList('parameters', i, v, true)} + index={i} + input={input[i]} + />) +}
    ); + +class AddStrategy extends Component { + + static propTypes () { + return { + input: PropTypes.object, + setValue: PropTypes.func, + updateInList: PropTypes.func, + incValue: PropTypes.func, + clear: PropTypes.func, + onCancel: PropTypes.func, + onSubmit: PropTypes.func, + editmode: PropTypes.bool, + initCallRequired: PropTypes.bool, + init: PropTypes.func, + }; + } + + componentWillMount () { + // TODO unwind this stuff + if (this.props.initCallRequired === true) { + this.props.init(this.props.input); + if (this.props.input.parameters) { + this.props.setValue('_params', this.props.input.parameters.length); + } + } + } + + + render () { + const { + input, + setValue, + updateInList, + incValue, + onCancel, + editmode = false, + onSubmit, + } = this.props; + + return ( +
    + {editmode ? : } + setValue('name', trim(target.value))} + value={input.name} + /> +
    + setValue('description', target.value)} + value={input.description} + /> + + + + { + e.preventDefault(); + incValue('_params'); + }}/>  Add parameter + + +
    +
    + + + + ); + } +} export default AddStrategy; diff --git a/frontend/src/component/strategies/edit-container.js b/frontend/src/component/strategies/edit-container.js new file mode 100644 index 0000000000..ccc852e1db --- /dev/null +++ b/frontend/src/component/strategies/edit-container.js @@ -0,0 +1,70 @@ +import { connect } from 'react-redux'; +import { hashHistory } from 'react-router'; +import { createMapper, createActions } from '../input-helpers'; +import { updateStrategy } from '../../store/strategy/actions'; + +import AddStrategy from './add-strategy'; + +const ID = 'edit-strategy'; + +function getId (props) { + return [ID, props.strategy.name]; +} + +// TODO: need to scope to the active strategy +// best is to emulate the "input-storage"? +const mapStateToProps = createMapper({ + id: getId, + getDefault: (state, ownProps) => ownProps.strategy, + prepare: (props) => { + props.editmode = true; + return props; + }, +}); + +const prepare = (methods, dispatch) => { + methods.onSubmit = (input) => ( + (e) => { + e.preventDefault(); + // clean + const parameters = (input.parameters || []) + .filter((name) => !!name) + .map(({ + name, + type = 'string', + description = '', + required = false, + }) => ({ + name, + type, + description, + required, + })); + + updateStrategy({ + name: input.name, + description: input.description, + parameters, + })(dispatch) + .then(() => methods.clear()) + .then(() => hashHistory.push(`/strategies/view/${input.name}`)); + } + ); + + methods.onCancel = (e) => { + e.preventDefault(); + methods.clear(); + // somewhat quickfix / hacky to go back.. + window.history.back(); + }; + + + return methods; +}; + +const actions = createActions({ + id: getId, + prepare, +}); + +export default connect(mapStateToProps, actions)(AddStrategy); diff --git a/frontend/src/component/strategies/list-component.jsx b/frontend/src/component/strategies/list-component.jsx index a0c45c06e4..7805ba44eb 100644 --- a/frontend/src/component/strategies/list-component.jsx +++ b/frontend/src/component/strategies/list-component.jsx @@ -1,8 +1,8 @@ import React, { Component } from 'react'; +import { Link } from 'react-router'; -import { List, ListItem, ListItemContent, Icon, IconButton, Chip } from 'react-mdl'; - -import style from './strategies.scss'; +import { List, ListItem, ListItemContent, IconButton } from 'react-mdl'; +import { HeaderTitle } from '../common'; class StrategiesListComponent extends Component { @@ -14,33 +14,29 @@ class StrategiesListComponent extends Component { this.props.fetchStrategies(); } - getParameterMap ({ parametersTemplate }) { - return Object.keys(parametersTemplate || {}).map(k => ( - {k} - )); - } - render () { const { strategies, removeStrategy } = this.props; return (
    -
    Strategies
    - this.context.router.push('/strategies/create')} title="Add new strategy"/> - -
    - - {strategies.length > 0 ? strategies.map((strategy, i) => { - return ( - - {strategy.name} {strategy.description} - removeStrategy(strategy)} /> - - ); - }) : No entries} - - - + this.context.router.push('/strategies/create')} + title="Add new strategy" />} /> + + {strategies.length > 0 ? strategies.map((strategy, i) => ( + + + + {strategy.name} + + + removeStrategy(strategy)} /> + + )) : No entries} +
    ); } diff --git a/frontend/src/component/strategies/list-container.jsx b/frontend/src/component/strategies/list-container.jsx index 1b1d1eb87e..50be30b13b 100644 --- a/frontend/src/component/strategies/list-container.jsx +++ b/frontend/src/component/strategies/list-container.jsx @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import StrategiesListComponent from './list-component.jsx'; -import { fetchStrategies, removeStrategy } from '../../store/strategy-actions'; +import { fetchStrategies, removeStrategy } from '../../store/strategy/actions'; const mapStateToProps = (state) => { const list = state.strategies.get('list').toArray(); diff --git a/frontend/src/component/strategies/show-strategy-component.js b/frontend/src/component/strategies/show-strategy-component.js new file mode 100644 index 0000000000..9930252eb8 --- /dev/null +++ b/frontend/src/component/strategies/show-strategy-component.js @@ -0,0 +1,69 @@ +import React, { PropTypes, PureComponent } from 'react'; +import { Grid, Cell, List, ListItem, ListItemContent } from 'react-mdl'; +import { AppsLinkList, TogglesLinkList } from '../common'; + +class ShowStrategyComponent extends PureComponent { + static propTypes () { + return { + toggles: PropTypes.array, + applications: PropTypes.array, + strategy: PropTypes.object.isRequired, + }; + } + + renderParameters (params) { + if (params) { + return params.map(({ name, type, description, required }, i) => ( + + + {name} ({type}) + + + )); + } else { + return (no params); + } + } + + render () { + const { + strategy, + applications, + toggles, + } = this.props; + + const { + parameters = [], + } = strategy; + + return ( +
    + + + +
    Parameters
    +
    + + {this.renderParameters(parameters)} + +
    + + +
    Applications using this strategy
    +
    + +
    + + +
    Toggles using this strategy
    +
    + +
    +
    +
    + ); + } +} + + +export default ShowStrategyComponent; diff --git a/frontend/src/component/strategies/strategy-details-component.jsx b/frontend/src/component/strategies/strategy-details-component.jsx new file mode 100644 index 0000000000..dc5dba66a6 --- /dev/null +++ b/frontend/src/component/strategies/strategy-details-component.jsx @@ -0,0 +1,78 @@ +import React, { PropTypes, Component } from 'react'; +import { hashHistory } from 'react-router'; +import { Tabs, Tab, ProgressBar } from 'react-mdl'; +import ShowStrategy from './show-strategy-component'; +import EditStrategy from './edit-container'; +import { HeaderTitle } from '../common'; + +const TABS = { + view: 0, + edit: 1, +}; + +export default class StrategyDetails extends Component { + static propTypes () { + return { + strategyName: PropTypes.string.isRequired, + toggles: PropTypes.array, + applications: PropTypes.array, + activeTab: PropTypes.string.isRequired, + strategy: PropTypes.object.isRequired, + fetchStrategies: PropTypes.func.isRequired, + fetchApplications: PropTypes.func.isRequired, + fetchFeatureToggles: PropTypes.func.isRequired, + }; + } + + componentDidMount () { + if (!this.props.strategy) { + this.props.fetchStrategies(); + }; + if (!this.props.applications || this.props.applications.length === 0) { + this.props.fetchApplications(); + } + if (!this.props.toggles || this.props.toggles.length === 0) { + this.props.fetchFeatureToggles(); + } + } + + getTabContent (activeTabId) { + if (activeTabId === TABS.edit) { + return ; + } else { + return (); + } + } + + goToTab (tabName) { + hashHistory.push(`/strategies/${tabName}/${this.props.strategyName}`); + } + + render () { + const activeTabId = TABS[this.props.activeTab] ? TABS[this.props.activeTab] : TABS.view; + const strategy = this.props.strategy; + if (!strategy) { + return ; + } + + const tabContent = this.getTabContent(activeTabId); + + return ( +
    + + + this.goToTab('view')}>Details + this.goToTab('edit')}>Edit + +
    +
    + {tabContent} +
    +
    +
    + ); + } +} diff --git a/frontend/src/component/strategies/strategy-details-container.js b/frontend/src/component/strategies/strategy-details-container.js new file mode 100644 index 0000000000..66e82d91d5 --- /dev/null +++ b/frontend/src/component/strategies/strategy-details-container.js @@ -0,0 +1,33 @@ +import { connect } from 'react-redux'; +import ShowStrategy from './strategy-details-component'; +import { fetchStrategies } from '../../store/strategy/actions'; +import { fetchAll } from '../../store/application/actions'; +import { fetchFeatureToggles } from '../../store/feature-actions'; + +const mapStateToProps = (state, props) => { + let strategy = state.strategies + .get('list') + .find(n => n.name === props.strategyName); + const applications = state.applications + .get('list') + .filter(app => app.strategies.includes(props.strategyName)); + const toggles = state.features + .filter(toggle => + toggle.get('strategies').findIndex(s => s.name === props.strategyName) > -1); + + return { + strategy, + strategyName: props.strategyName, + applications: applications && applications.toJS(), + toggles: toggles && toggles.toJS(), + activeTab: props.activeTab, + }; +}; + +const Constainer = connect(mapStateToProps, { + fetchStrategies, + fetchApplications: fetchAll, + fetchFeatureToggles, +})(ShowStrategy); + +export default Constainer; diff --git a/frontend/src/component/user/user-component.jsx b/frontend/src/component/user/user-component.jsx index 6d4d717df1..15692b13e8 100644 --- a/frontend/src/component/user/user-component.jsx +++ b/frontend/src/component/user/user-component.jsx @@ -6,6 +6,7 @@ class EditUserComponent extends React.Component { return { user: PropTypes.object.isRequired, updateUserName: PropTypes.func.isRequired, + save: PropTypes.func.isRequired, }; } @@ -21,7 +22,7 @@ class EditUserComponent extends React.Component { Action required

    - You are logged in as:You hav to specify a username to use Unleash. This will allow us to track changes. + You hav to specify a username to use Unleash. This will allow us to track changes.

    response.json()); } +function fetchApplicationsWithStrategyName (strategyName) { + return fetch(`${URI}?strategyName=${strategyName}`, { headers }) + .then(throwIfNotSuccess) + .then(response => response.json()); +} + +function storeApplicationMetaData (appName, key, value) { + const data = {}; + data[key] = value; + return fetch(`${URI}/${appName}`, { + method: 'POST', + headers, + body: JSON.stringify(data), + credentials: 'include', + }).then(throwIfNotSuccess); +} + module.exports = { fetchApplication, fetchAll, + fetchApplicationsWithStrategyName, + storeApplicationMetaData, }; diff --git a/frontend/src/data/archive-api.js b/frontend/src/data/archive-api.js index bd899214df..07dd762f16 100644 --- a/frontend/src/data/archive-api.js +++ b/frontend/src/data/archive-api.js @@ -8,11 +8,10 @@ function fetchAll () { .then(response => response.json()); } -function revive (feature) { - return fetch(`${URI}/revive`, { +function revive (featureName) { + return fetch(`${URI}/revive/${featureName}`, { method: 'POST', headers, - body: JSON.stringify(feature), credentials: 'include', }).then(throwIfNotSuccess); } diff --git a/frontend/src/data/client-strategy-api.js b/frontend/src/data/client-strategy-api.js deleted file mode 100644 index 1681d74be1..0000000000 --- a/frontend/src/data/client-strategy-api.js +++ /dev/null @@ -1,13 +0,0 @@ -import { throwIfNotSuccess, headers } from './helper'; - -const URI = '/api/client/strategies'; - -function fetchAll () { - return fetch(URI, { headers }) - .then(throwIfNotSuccess) - .then(response => response.json()); -} - -module.exports = { - fetchAll, -}; diff --git a/frontend/src/data/helper.js b/frontend/src/data/helper.js index 4d396f379d..0775a699c6 100644 --- a/frontend/src/data/helper.js +++ b/frontend/src/data/helper.js @@ -1,11 +1,18 @@ const defaultErrorMessage = 'Unexptected exception when talking to unleash-api'; +function extractJoiMsg (body) { + return body.details.length > 0 ? body.details[0].message : defaultErrorMessage; +} +function extractLegacyMsg (body) { + return body && body.length > 0 ? body[0].msg : defaultErrorMessage; +} + 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; + const errorMsg = body && body.isJoi ? extractJoiMsg(body) : extractLegacyMsg(body); let error = new Error(errorMsg); error.statusCode = response.status; reject(error); diff --git a/frontend/src/data/metrics-api.js b/frontend/src/data/metrics-api.js deleted file mode 100644 index 53d1867894..0000000000 --- a/frontend/src/data/metrics-api.js +++ /dev/null @@ -1,13 +0,0 @@ -import { throwIfNotSuccess } from './helper'; - -const URI = '/api/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 index 7930f4c901..d400882487 100644 --- a/frontend/src/data/strategy-api.js +++ b/frontend/src/data/strategy-api.js @@ -17,6 +17,15 @@ function create (strategy) { }).then(throwIfNotSuccess); } +function update (strategy) { + return fetch(`${URI}/${strategy.name}`, { + method: 'put', + headers, + body: JSON.stringify(strategy), + credentials: 'include', + }).then(throwIfNotSuccess); +} + function remove (strategy) { return fetch(`${URI}/${strategy.name}`, { method: 'DELETE', @@ -28,5 +37,6 @@ function remove (strategy) { module.exports = { fetchAll, create, + update, remove, }; diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 905c9c3dfd..7544329930 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -1,4 +1,7 @@ +import 'react-mdl/extra/material.css'; +import 'react-mdl/extra/material.js'; import 'whatwg-fetch'; + import React from 'react'; import ReactDOM from 'react-dom'; import { Router, Route, IndexRedirect, hashHistory } from 'react-router'; @@ -11,15 +14,15 @@ import App from './component/app'; import Features from './page/features'; import CreateFeatureToggle from './page/features/create'; -import EditFeatureToggle from './page/features/edit'; +import ViewFeatureToggle from './page/features/show'; import Strategies from './page/strategies'; +import StrategyView from './page/strategies/show'; import CreateStrategies from './page/strategies/create'; import HistoryPage from './page/history'; import HistoryTogglePage from './page/history/toggle'; import Archive from './page/archive'; import Applications from './page/applications'; import ApplicationView from './page/applications/view'; -import ClientStrategies from './page/client-strategies'; const unleashStore = createStore( store, @@ -28,22 +31,37 @@ const unleashStore = createStore( ) ); +// "pageTitle" and "link" attributes are for internal usage only + ReactDOM.render( - - - - - - - + + + + + + + + + + + + + + + + + + - - - + + + + + , document.getElementById('app')); diff --git a/frontend/src/page/applications/view.js b/frontend/src/page/applications/view.js index e4bc3b2405..2ecfa22355 100644 --- a/frontend/src/page/applications/view.js +++ b/frontend/src/page/applications/view.js @@ -1,6 +1,10 @@ -import React from 'react'; +import React, { PropTypes } from 'react'; import ApplicationEditComponent from '../../component/application/application-edit-container'; const render = ({ params }) => ; +render.propTypes = { + params: PropTypes.object.isRequired, +}; + export default render; diff --git a/frontend/src/page/client-strategies/index.js b/frontend/src/page/client-strategies/index.js deleted file mode 100644 index 1c5e66849c..0000000000 --- a/frontend/src/page/client-strategies/index.js +++ /dev/null @@ -1,11 +0,0 @@ -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 index 784c9f6dbe..f621b98def 100644 --- a/frontend/src/page/features/create.js +++ b/frontend/src/page/features/create.js @@ -1,11 +1,7 @@ import React from 'react'; import AddFeatureToggleForm from '../../component/feature/form-add-container'; -const render = () => ( -
    -
    Create feature toggle
    - -
    -); + +const render = () => (); export default render; diff --git a/frontend/src/page/features/edit.js b/frontend/src/page/features/edit.js deleted file mode 100644 index 5f0cd3a4b3..0000000000 --- a/frontend/src/page/features/edit.js +++ /dev/null @@ -1,16 +0,0 @@ -import React, { Component, PropTypes } from 'react'; -import EditFeatureToggleForm from '../../component/feature/view-edit-container'; - -export default class Features extends Component { - static propTypes () { - return { - params: PropTypes.object.isRequired, - }; - } - - render () { - return ( - - ); - } -}; diff --git a/frontend/src/page/features/show.js b/frontend/src/page/features/show.js new file mode 100644 index 0000000000..7cff11b9f1 --- /dev/null +++ b/frontend/src/page/features/show.js @@ -0,0 +1,17 @@ +import React, { PureComponent, PropTypes } from 'react'; +import ViewFeatureToggle from '../../component/feature/view-container'; + +export default class Features extends PureComponent { + static propTypes () { + return { + params: PropTypes.object.isRequired, + }; + } + + render () { + const { params } = this.props; + return ( + + ); + } +}; diff --git a/frontend/src/page/history/toggle.js b/frontend/src/page/history/toggle.js index eae6094429..95b4da6ac0 100644 --- a/frontend/src/page/history/toggle.js +++ b/frontend/src/page/history/toggle.js @@ -1,5 +1,5 @@ import React, { PropTypes } from 'react'; -import HistoryListToggle from '../../component/history/history-list-toggle-component'; +import HistoryListToggle from '../../component/history/history-list-toggle-container'; const render = ({ params }) => ; diff --git a/frontend/src/page/strategies/show.js b/frontend/src/page/strategies/show.js new file mode 100644 index 0000000000..0dc4fdbc76 --- /dev/null +++ b/frontend/src/page/strategies/show.js @@ -0,0 +1,10 @@ +import React, { PropTypes } from 'react'; +import ShowStrategy from '../../component/strategies/strategy-details-container'; + +const render = ({ params }) => ; + +render.propTypes = { + params: PropTypes.object.isRequired, +}; + +export default render; diff --git a/frontend/src/store/application/actions.js b/frontend/src/store/application/actions.js index 311c5d6b10..3f295bd4c3 100644 --- a/frontend/src/store/application/actions.js +++ b/frontend/src/store/application/actions.js @@ -2,6 +2,7 @@ import api from '../../data/applications-api'; export const RECEIVE_ALL_APPLICATIONS = 'RECEIVE_ALL_APPLICATIONS'; export const ERROR_RECEIVE_ALL_APPLICATIONS = 'ERROR_RECEIVE_ALL_APPLICATIONS'; +export const ERROR_UPDATING_APPLICATION_DATA = 'ERROR_UPDATING_APPLICATION_DATA'; export const RECEIVE_APPLICATION = 'RECEIVE_APPLICATION'; @@ -15,8 +16,8 @@ const recieveApplication = (json) => ({ value: json, }); -const errorReceiveApplications = (statusCode) => ({ - type: ERROR_RECEIVE_ALL_APPLICATIONS, +const errorReceiveApplications = (statusCode, type = ERROR_RECEIVE_ALL_APPLICATIONS) => ({ + type, statusCode, }); @@ -26,6 +27,11 @@ export function fetchAll () { .catch(error => dispatch(errorReceiveApplications(error))); } +export function storeApplicationMetaData (appName, key, value) { + return dispatch => api.storeApplicationMetaData(appName, key, value) + .catch(error => dispatch(errorReceiveApplications(error, ERROR_UPDATING_APPLICATION_DATA))); +} + export function fetchApplication (appName) { return dispatch => api.fetchApplication(appName) .then(json => dispatch(recieveApplication(json))) diff --git a/frontend/src/store/client-strategy-actions.js b/frontend/src/store/client-strategy-actions.js deleted file mode 100644 index b7a07a0dad..0000000000 --- a/frontend/src/store/client-strategy-actions.js +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 1de24b1be0..0000000000 --- a/frontend/src/store/client-strategy-store.js +++ /dev/null @@ -1,17 +0,0 @@ -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-store.js b/frontend/src/store/error-store.js index 129f8b6c37..fd9422ae00 100644 --- a/frontend/src/store/error-store.js +++ b/frontend/src/store/error-store.js @@ -7,6 +7,13 @@ import { ERROR_UPDATE_FEATURE_TOGGLE, } from './feature-actions'; +import { + ERROR_UPDATING_STRATEGY, + ERROR_CREATING_STRATEGY, + ERROR_RECEIVE_STRATEGIES, + +} from './strategy/actions'; + const debug = require('debug')('unleash:error-store'); function getInitState () { @@ -29,6 +36,9 @@ const strategies = (state = getInitState(), action) => { case ERROR_REMOVE_FEATURE_TOGGLE: case ERROR_FETCH_FEATURE_TOGGLES: case ERROR_UPDATE_FEATURE_TOGGLE: + case ERROR_UPDATING_STRATEGY: + case ERROR_CREATING_STRATEGY: + case ERROR_RECEIVE_STRATEGIES: return addErrorIfNotAlreadyInList(state, action.error.message); case MUTE_ERROR: return state.update('list', (list) => list.remove(list.indexOf(action.error))); diff --git a/frontend/src/store/history-actions.js b/frontend/src/store/history-actions.js index 7a45e89d0c..485e172ea2 100644 --- a/frontend/src/store/history-actions.js +++ b/frontend/src/store/history-actions.js @@ -3,11 +3,18 @@ import api from '../data/history-api'; export const RECEIVE_HISTORY = 'RECEIVE_HISTORY'; export const ERROR_RECEIVE_HISTORY = 'ERROR_RECEIVE_HISTORY'; +export const RECEIVE_HISTORY_FOR_TOGGLE = 'RECEIVE_HISTORY_FOR_TOGGLE'; + const receiveHistory = (json) => ({ type: RECEIVE_HISTORY, value: json.events, }); +const receiveHistoryforToggle = (json) => ({ + type: RECEIVE_HISTORY_FOR_TOGGLE, + value: json, +}); + const errorReceiveHistory = (statusCode) => ({ type: ERROR_RECEIVE_HISTORY, statusCode, @@ -18,3 +25,10 @@ export function fetchHistory () { .then(json => dispatch(receiveHistory(json))) .catch(error => dispatch(errorReceiveHistory(error))); } + + +export function fetchHistoryForToggle (toggleName) { + return dispatch => api.fetchHistoryForToggle(toggleName) + .then(json => dispatch(receiveHistoryforToggle(json))) + .catch(error => dispatch(errorReceiveHistory(error))); +} diff --git a/frontend/src/store/history-store.js b/frontend/src/store/history-store.js index 790d85ec18..7ec098235a 100644 --- a/frontend/src/store/history-store.js +++ b/frontend/src/store/history-store.js @@ -1,12 +1,14 @@ import { List, Map as $Map } from 'immutable'; -import { RECEIVE_HISTORY } from './history-actions'; +import { RECEIVE_HISTORY, RECEIVE_HISTORY_FOR_TOGGLE } from './history-actions'; function getInitState () { - return new $Map({ list: new List() }); + return new $Map({ list: new List(), toggles: new $Map() }); } const historyStore = (state = getInitState(), action) => { switch (action.type) { + case RECEIVE_HISTORY_FOR_TOGGLE: + return state.setIn(['toggles', action.value.toggleName], new List(action.value.events)); case RECEIVE_HISTORY: return state.set('list', new List(action.value)); default: diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index 2ef2384523..0ca4a4b88f 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -1,13 +1,11 @@ import { combineReducers } from 'redux'; import features from './feature-store'; import featureMetrics from './feature-metrics-store'; -import strategies from './strategy-store'; +import strategies from './strategy'; 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'; import settings from './settings'; import user from './user'; @@ -21,8 +19,6 @@ const unleashStore = combineReducers({ history, archive, error, - metrics, - clientStrategies, clientInstances, settings, user, diff --git a/frontend/src/store/input-actions.js b/frontend/src/store/input-actions.js index b664bb1b98..5b6f5bead6 100644 --- a/frontend/src/store/input-actions.js +++ b/frontend/src/store/input-actions.js @@ -13,7 +13,7 @@ export const createInc = ({ id, key }) => ({ type: actions.INCREMENT_VALUE, id, 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, index }) => ({ type: actions.LIST_POP, id, key, index }); -export const createUp = ({ id, key, index, newValue }) => ({ type: actions.LIST_UP, id, key, index, newValue }); +export const createUp = ({ id, key, index, newValue, merge }) => ({ type: actions.LIST_UP, id, key, index, newValue, merge }); 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 index 70de30ccf4..743c7c570f 100644 --- a/frontend/src/store/input-store.js +++ b/frontend/src/store/input-store.js @@ -48,11 +48,18 @@ function addToList (state, { id, key, value }) { return state.updateIn(id.concat([key]), (list) => list.push(value)); } -function updateInList (state, { id, key, index, newValue }) { +function updateInList (state, { id, key, index, newValue, merge }) { state = assertId(state, id); state = assertList(state, id, key); - return state.updateIn(id.concat([key]), (list) => list.set(index, newValue)); + return state.updateIn(id.concat([key]), (list) => { + if (merge && list.has(index)) { + newValue = list.get(index).merge(new $Map(newValue)); + } else if (typeof newValue !== 'string' ) { + newValue = fromJS(newValue); + } + return list.set(index, newValue); + }); } function removeFromList (state, { id, key, index }) { diff --git a/frontend/src/store/metrics-actions.js b/frontend/src/store/metrics-actions.js deleted file mode 100644 index 7010d3857d..0000000000 --- a/frontend/src/store/metrics-actions.js +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index f0b7a4a650..0000000000 --- a/frontend/src/store/metrics-store.js +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 8421a1384c..0000000000 --- a/frontend/src/store/strategy-actions.js +++ /dev/null @@ -1,61 +0,0 @@ -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/actions.js b/frontend/src/store/strategy/actions.js new file mode 100644 index 0000000000..bed0dd40e9 --- /dev/null +++ b/frontend/src/store/strategy/actions.js @@ -0,0 +1,88 @@ +import api from '../../data/strategy-api'; +import { fetchApplicationsWithStrategyName } from '../../data/applications-api'; + +export const ADD_STRATEGY = 'ADD_STRATEGY'; +export const UPDATE_STRATEGY = 'UPDATE_STRATEGY'; +export const REMOVE_STRATEGY = 'REMOVE_STRATEGY'; +export const REQUEST_STRATEGIES = 'REQUEST_STRATEGIES'; +export const START_CREATE_STRATEGY = 'START_CREATE_STRATEGY'; +export const START_UPDATE_STRATEGY = 'START_UPDATE_STRATEGY'; +export const RECEIVE_STRATEGIES = 'RECEIVE_STRATEGIES'; +export const ERROR_RECEIVE_STRATEGIES = 'ERROR_RECEIVE_STRATEGIES'; +export const ERROR_CREATING_STRATEGY = 'ERROR_CREATING_STRATEGY'; +export const ERROR_UPDATING_STRATEGY = 'ERROR_UPDATING_STRATEGY'; + +const addStrategy = (strategy) => ({ type: ADD_STRATEGY, strategy }); +const createRemoveStrategy = (strategy) => ({ type: REMOVE_STRATEGY, strategy }); +const updatedStrategy = (strategy) => ({ type: UPDATE_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, +}); + +const startUpdate = () => ({ type: START_UPDATE_STRATEGY }); + +function dispatchAndThrow (dispatch, type) { + return (error) => { + dispatch({ type, error, receivedAt: Date.now() }); + throw error; + }; +} + +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 updateStrategy (strategy) { + return dispatch => { + dispatch(startUpdate()); + + return api.update(strategy) + .then(() => dispatch(updatedStrategy(strategy))) + .catch(dispatchAndThrow(dispatch, ERROR_UPDATING_STRATEGY)); + }; +} + + +export function removeStrategy (strategy) { + return dispatch => api.remove(strategy) + .then(() => dispatch(createRemoveStrategy(strategy))) + .catch(error => dispatch(errorCreatingStrategy(error))); +} + +export function getApplicationsWithStrategy (strategyName) { + return fetchApplicationsWithStrategyName(strategyName); +} + diff --git a/frontend/src/store/strategy-store.js b/frontend/src/store/strategy/index.js similarity index 64% rename from frontend/src/store/strategy-store.js rename to frontend/src/store/strategy/index.js index 72900eaf22..125f3fc530 100644 --- a/frontend/src/store/strategy-store.js +++ b/frontend/src/store/strategy/index.js @@ -1,5 +1,5 @@ import { List, Map as $Map } from 'immutable'; -import { RECEIVE_STRATEGIES, REMOVE_STRATEGY, ADD_STRATEGY } from './strategy-actions'; +import { RECEIVE_STRATEGIES, REMOVE_STRATEGY, ADD_STRATEGY, UPDATE_STRATEGY } from './actions'; function getInitState () { return new $Map({ list: new List() }); @@ -13,6 +13,16 @@ function removeStrategy (state, action) { return state; } +function updateStrategy (state, action) { + return state.update('list', (list) => list.map(strategy => { + if (strategy.name === action.strategy.name) { + return action.strategy; + } else { + return strategy; + } + })); +} + const strategies = (state = getInitState(), action) => { switch (action.type) { case RECEIVE_STRATEGIES: @@ -21,6 +31,8 @@ const strategies = (state = getInitState(), action) => { return removeStrategy(state, action); case ADD_STRATEGY: return state.update('list', (list) => list.push(action.strategy)); + case UPDATE_STRATEGY: + return updateStrategy(state, action); default: return state; } diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 01c643ac5e..1ec6a9fd0b 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -38,16 +38,20 @@ module.exports = { module: { loaders: [ { - test: /\.jsx?$/, + test: /\.(jsx|js)$/, exclude: /node_modules/, loaders: ['babel'], include: path.join(__dirname, 'src'), }, { - test: /(\.scss|\.css)$/, + test: /(\.scss)$/, loader: ExtractTextPlugin.extract('style', 'css?sourceMap&modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]!postcss!sass'), }, + { + test: /\.css$/, + loader: 'style-loader!css-loader', + }, ], },