1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-23 13:46:45 +02:00

Merge pull request #14 from Unleash/react-mdl-wip

Convert to React-MDL and feature creep
This commit is contained in:
Ivar Conradi Østhus 2016-12-20 19:36:46 +01:00 committed by GitHub
commit 85640f4d04
83 changed files with 1808 additions and 913 deletions

16
frontend/.editorconfig Normal file
View File

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

1
frontend/.gitignore vendored
View File

@ -36,3 +36,4 @@ jspm_packages
# Optional REPL history
.node_repl_history
typings*

View File

@ -3,7 +3,6 @@
<head>
<title>Unleash Admin</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/node_modules/react-mdl/extra/material.min.css">
<link rel="stylesheet" href="/static/bundle.css" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet">

View File

@ -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",

View File

@ -20,6 +20,7 @@
}
},
"rules": {
"no-shadow": 0
"no-shadow": 0,
"react/sort-comp": 0
}
}

View File

@ -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 (
<span>
{result.map((entry, index) => (
<span key={entry.link + index}><Link style={{ color: '#f1f1f1', textDecoration: 'none' }} to={entry.link}>
{entry.name}
</Link> {(index + 1) < result.length ? ' / ' : null}</span>
))}
</span>
);
}
render () {
const createListItem = (path, caption) =>
const createListItem = (path, caption, icon) =>
<a
href={this.context.router.createHref(path)}
className={this.context.router.isActive(path) ? style.active : ''}>
{caption}
{icon && <Icon name={icon} />} {caption}
</a>;
return (
<div style={{}}>
<UserContainer />
<Layout fixedHeader>
<Header title={<span><span style={{ color: '#ddd' }}>Unleash Admin / </span><strong>{this.getCurrentSection()}</strong></span>}>
<Header title={this.getTitleWithLinks()}>
<Navigation>
<a href="https://github.com/Unleash" target="_blank">Github</a>
<ShowUserContainer />
@ -54,19 +116,15 @@ export default class App extends Component {
</Header>
<Drawer title="Unleash Admin">
<Navigation>
{createListItem('/features', 'Feature toggles')}
{createListItem('/strategies', 'Strategies')}
{createListItem('/history', 'Event history')}
{createListItem('/archive', 'Archived toggles')}
<hr />
{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')}
</Navigation>
</Drawer>
<Content>
<Grid>
<Grid shadow={1} style={{ maxWidth: '1200px', margin: '0 auto' }}>
<Cell col={12}>
{this.props.children}
<ErrorContainer />
@ -85,18 +143,6 @@ export default class App extends Component {
<FooterDropDownSection title="Metrics">
<FooterLinkList>
{createListItem('/applications', 'Applications')}
{createListItem('/metrics', 'Client metrics')}
{createListItem('/client-strategies', 'Client strategies')}
{createListItem('/client-instances', 'Client instances')}
</FooterLinkList>
</FooterDropDownSection>
<FooterDropDownSection title="FAQ">
<FooterLinkList>
<a href="#">Help</a>
<a href="#">Privacy & Terms</a>
<a href="#">Questions</a>
<a href="#">Answers</a>
<a href="#">Contact Us</a>
</FooterLinkList>
</FooterDropDownSection>
<FooterDropDownSection title="Clients">
@ -119,29 +165,5 @@ export default class App extends Component {
</Layout>
</div>
);
return (
<div className={style.container}>
<AppBar title="Unleash Admin" leftIcon="menu" onLeftIconClick={this.toggleDrawerActive} className={style.appBar}>
</AppBar>
<div className={style.container} style={{ top: '6.4rem' }}>
<Layout>
<NavDrawer active={this.state.drawerActive} permanentAt="sm" onOverlayClick={this.onOverlayClick} >
<Navigation />
</NavDrawer>
<Panel scrollY>
<div style={{ padding: '1.8rem' }}>
{this.props.children}
</div>
</Panel>
</Layout>
</div>
</div>
);
}
};

View File

@ -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 (<Textfield
style={{ width: '100%' }}
label={this.props.label}
floatingLabel
rows={this.props.rows}
value={this.state.value}
onChange={this.setValue}
onBlur={this.props.onBlur} />
);
}
}
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 <div>Loading application info...</div>;
return <ProgressBar indeterminate />;
}
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 ? (
<Grid>
<Cell col={3} tablet={4} phone={12}>
<h6> Toggles</h6>
<hr />
<List>
{seenToggles.map(({ name, description, enabled, notFound }, i) =>
(notFound ?
<ListItem twoLine key={i}>
<ListItemContent icon={'report'} subtitle={'Missing, want to create?'}>
<Link to={`/features/create?name=${name}`}>
{name}
</Link>
</ListItemContent>
</ListItem> :
<ListItem twoLine key={i}>
<ListItemContent icon={<span><Switch disabled checked={!!enabled} /></span>} subtitle={description}>
<Link to={`/features/edit/${name}`}>
{name}
</Link>
</ListItemContent>
</ListItem>)
)}
</List>
</Cell>
<Cell col={3} tablet={4} phone={12}>
<h6>Implemented strategies</h6>
<hr />
<List>
{strategies.map(({ name, description, notFound }, i) => (
notFound ?
<ListItem twoLine key={`${name}-${i}`}>
<ListItemContent icon={'report'} subtitle={'Missing, want to create?'}>
<Link to={`/strategies/create?name=${name}`}>
{name}
</Link>
</ListItemContent>
</ListItem> :
<ListItem twoLine key={`${name}-${i}`}>
<ListItemContent icon={'extension'} subtitle={description}>
<Link to={`/strategies/view/${name}`}>
{name}
</Link>
</ListItemContent>
</ListItem>
))}
</List>
</Cell>
<Cell col={6} tablet={12}>
<h6>{instances.length} Instances connected</h6>
<hr />
<List>
{instances.map(({ instanceId, clientIp, lastSeen }, i) => (
<ListItem key={i} twoLine>
<ListItemContent
icon="timeline"
subtitle={
<span>{clientIp} last seen at <small>{new Date(lastSeen).toLocaleString('nb-NO')}</small></span>
}>
{instanceId}
</ListItemContent>
</ListItem>
))}
</List>
</Cell>
</Grid>) : (
<Grid>
<Cell col={12}>
<h5>Edit app meta data</h5>
</Cell>
<Cell col={6} tablet={12}>
<StatefulTextfield
value={url} label="URL" onBlur={(e) => storeApplicationMetaData(appName, 'url', e.target.value)} /><br />
<StatefulTextfield
value={description}
label="Description" rows={5} onBlur={(e) => storeApplicationMetaData(appName, 'description', e.target.value)} />
</Cell>
<Cell col={6} tablet={12}>
<StatefulTextfield
value={icon} label="Select icon" onBlur={(e) => storeApplicationMetaData(appName, 'icon', e.target.value)} />
<StatefulTextfield
value={color} label="Select color" onBlur={(e) => storeApplicationMetaData(appName, 'color', e.target.value)} />
</Cell>
</Grid>);
return (
<div>
<h5>{appName}</h5>
<Grid>
<Cell col={4}>
<h6>Instances</h6>
<ol className="demo-list-item mdl-list">
{instances.map(({ instanceId }, i) => <li className="mdl-list__item" key={i}>{instanceId}</li>)}
</ol>
</Cell>
<Cell col={4}>
<h6>Strategies</h6>
<ol className="demo-list-item mdl-list">
{/*strategies.map((name, i) => <li className="mdl-list__item" key={i}>{name}</li>)*/}
</ol>
</Cell>
<Cell col={4}>
<h6>Toggles</h6>
<ol className="demo-list-item mdl-list">
{seenToggles.map((name, i) => <li className="mdl-list__item" key={i}>
<Link to={`/features/edit/${name}`}>
{name}
</Link>
</li>)}
</ol>
</Cell>
</Grid>
<HeaderTitle title={<span><Icon name={icon} /> {appName}</span>} subtitle={description}
actions={url && <ExternalIconLink url={url}>Visit site</ExternalIconLink>}
/>
<Tabs activeTab={this.state.activeTab} onChange={(tabId) => this.setState({ activeTab: tabId })} ripple>
<Tab>Metrics</Tab>
<Tab>Edit</Tab>
</Tabs>
{content}
</div>
);
}
}
export default ClientStrategies;
export default ClientApplications;

View File

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

View File

@ -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 <div>loading...</div>;
return <ProgressBar indeterminate />;
}
return (
<div>
{applications.map(item => (
<Link key={item.appName} to={`/applications/${item.appName}`}>
Link: {item.appName}
</Link>
))}
<HeaderTitle title="Applications" />
<AppsLinkList apps={applications} />
</div>
);
}

View File

@ -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 (
<div>
<h6>Toggle Archive</h6>
<DataTable
rows={archive}
style={{ width: '100%' }}>
<TableHeader style={{ width: '25px' }} name="strategies" cellFormatter={(name) => (
<IconButton colored name="undo" onClick={() => revive(name)} />
)}>Revive</TableHeader>
<TableHeader style={{ width: '25px' }} name="enabled" cellFormatter={(v) => (v ? 'Yes' : '-')}>Enabled</TableHeader>
<TableHeader name="name">Toggle name</TableHeader>
<TableHeader numeric name="createdAt">Created</TableHeader>
</DataTable>
<HeaderTitle title="Toggle Archive" />
{
archive.length > 0 ?
<DataTable
rows={archive}
style={{ width: '100%' }}>
<TableHeader style={{ width: '25px' }} name="reviveName" cellFormatter={(reviveName) => (
<IconButton colored name="undo" onClick={() => revive(reviveName)} />
)}>Revive</TableHeader>
<TableHeader style={{ width: '25px' }} name="enabled" cellFormatter={(v) => (v ? 'Yes' : '-')}>Enabled</TableHeader>
<TableHeader name="name">Toggle name</TableHeader>
<TableHeader numeric name="createdAt">Created</TableHeader>
</DataTable> :
<div style={{ textAlign: 'center' }}>
<Icon name="report" style={{ color: '#aaa', fontSize: '40px' }}/><br />
No archived feature toggles, go see <Link to="/features">active toggles here</Link>
</div>
}
</div>
);
}

View File

@ -22,14 +22,14 @@ class ClientStrategies extends Component {
rows={source}
selectable={false}
>
<TableHeader name="instanceId">Instance ID</TableHeader>
<TableHeader name="appName">Application name</TableHeader>
<TableHeader name="clientIp">IP</TableHeader>
<TableHeader name="createdAt">Created</TableHeader>
<TableHeader name="lastSeen">Last Seen</TableHeader>
</DataTable>
);
}

View File

@ -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 (
<DataTable
style={{ width: '100%' }}
rows={source}
selectable={false}
>
<TableHeader name="appName">Application name</TableHeader>
<TableHeader name="strategies">Strategies</TableHeader>
</DataTable>
);
}
}
export default ClientStrategies;

View File

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

View File

@ -0,0 +1,5 @@
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -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 }) => (
<List style={{ textAlign: 'left' }}>
{apps.length > 0 && apps.map(({ appName, description = '-', icon = 'apps' }) => (
<ListItem twoLine key={appName}>
<ListItemContent avatar={icon} subtitle={shorten(description)}>
<Link key={appName} to={`/applications/${appName}`}>
{appName}
</Link>
</ListItemContent>
</ListItem>
))}
</List>
);
export const HeaderTitle = ({ title, actions, subtitle }) => (
<div style={{ display: 'flex', borderBottom: '1px solid #f1f1f1', marginBottom: '10px', padding: '16px 20px ' }}>
<div style={{ flex: '2' }}>
<h6 style={{ margin: 0 }}>{title}</h6>
{subtitle && <small>{subtitle}</small>}
</div>
{actions && <div style={{ flex: '1', textAlign: 'right' }}>{actions}</div>}
</div>
);
export const FormButtons = ({ submitText = 'Create', onCancel }) => (
<div>
<Button type="submit" ripple raised primary icon="add">
<Icon name="add" />&nbsp;&nbsp;&nbsp;
{ submitText }
</Button>
&nbsp;
<Button type="cancel" ripple raised onClick={onCancel} style={{ float: 'right' }}>
<Icon name="cancel" />&nbsp;&nbsp;&nbsp;
Cancel
</Button>
</div>
);
export const SwitchWithLabel = ({ onChange, children, checked }) => (
<span>
<span style={{ cursor: 'pointer', display: 'inline-block', width: '45px' }}>
<Switch onChange={onChange} checked={checked} />
</span>
<span>{children}</span>
</span>
);
export const TogglesLinkList = ({ toggles }) => (
<List style={{ textAlign: 'left' }} className={styles.truncate}>
{toggles.length > 0 && toggles.map(({ name, description = '-', icon = 'toggle' }) => (
<ListItem twoLine key={name}>
<ListItemContent avatar={icon} subtitle={description}>
<Link key={name} to={`/features/edit/${name}`}>
{name}
</Link>
</ListItemContent>
</ListItem>
))}
</List>
);
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 }) => (
<a {...props} style={{ textDecoration: 'none' }}>
<Icon name={icon} style={{ marginRight: '5px', verticalAlign: 'middle' }}/>
<span style={{ textDecoration: 'none', verticalAlign: 'middle' }}>{children}</span>
</a>
);
export const ExternalIconLink = ({ url, children }) => (
<IconLink icon="queue" href={url} target="_blank" rel="noopener">
{children}
</IconLink>
);
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);
};

View File

@ -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 {
<Snackbar
action="Dismiss"
active={showError}
icon="question_answer"
timeout={10000}
label={error}
onClick={muteError}
onActionClick={muteError}
onTimeout={muteError}
type="warning"
/>
timeout={10000}
>
<Icon name="question_answer" /> {error}
</Snackbar>
);
}
}

View File

@ -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 (
<li key={name} className="mdl-list__item">
@ -29,19 +29,21 @@ const Feature = ({
<div style={{ width: '40px', textAlign: 'center' }}>
{
isStale ?
<Icon style={{ width: '25px', marginTop: '4px', fontSize: '25px', color: '#ccc' }} name="report problem" title="No metrics avaiable" /> :
<div>
<Progress strokeWidth={15} percentage={percent} width="50" />
</div>
<Icon
style={{ width: '25px', marginTop: '4px', fontSize: '25px', color: '#ccc' }}
name="report problem" title="No metrics avaiable" /> :
<div>
<Progress strokeWidth={15} percentage={percent} width="50" />
</div>
}
</div>
&nbsp;
<span style={{ display: 'inline-block', width: '45px' }} title={`Toggle ${name}`}>
<span style={{ display: 'inline-block', width: '45px' }} title={`Toggle ${name}`}>
<Switch title="test" key="left-actions" onChange={() => onFeatureClick(feature)} checked={enabled} />
</span>
<Link to={`/features/edit/${name}`} className={style.link}>
{name} <small>{(description && description.substring(0, 100)) || ''}</small>
<Link to={`/features/view/${name}`} className={style.link}>
{name} <small>{shorten(description, 30) || ''}</small>
</Link>
</span>
@ -52,7 +54,7 @@ const Feature = ({
<Link to={`/features/edit/${name}`} title={`Edit ${name}`} className={style.iconListItem}>
<IconButton name="edit" />
</Link>
<Link to={`/history/${name}`} title={`History htmlFor ${name}`} className={style.iconListItem}>
<Link to={`features/history/${name}`} title={`History htmlFor ${name}`} className={style.iconListItem}>
<IconButton name="history" />
</Link>
<IconButton name="delete" onClick={() => onFeatureRemove(name)} className={style.iconListItem} />

View File

@ -58,4 +58,10 @@
.topListItem2 {
flex: 2;
}
}
@media (max-width: 960px) {
.iconListItemChip {
display: none;
}
}

View File

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

View File

@ -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) => {

View File

@ -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 (
<form onSubmit={onSubmit(input)}>
{title && <HeaderTitle title={title} />}
<section>
<Textfield
floatingLabel
label="Name"
name="name"
disabled={editmode}
@ -54,7 +59,9 @@ class AddFeatureToggleComponent extends Component {
onChange={(v) => setValue('name', trim(v.target.value))} />
<br />
<Textfield
rows={2}
floatingLabel
style={{ width: '100%' }}
rows={5}
label="Description"
required
value={description}
@ -64,11 +71,10 @@ class AddFeatureToggleComponent extends Component {
<Switch
checked={enabled}
onChange={(v) => {
// todo is wrong way to get value?
setValue('enabled', (console.log(v.target) && v.target.value === 'on'));
onChange={() => {
setValue('enabled', !enabled);
}}>Enabled</Switch>
<br />
<hr />
</section>
<StrategiesSection
@ -78,9 +84,10 @@ class AddFeatureToggleComponent extends Component {
removeStrategy={removeStrategy} />
<br />
<Button type="submit" raised primary>{editmode ? 'Update' : 'Create'}</Button>
&nbsp;
<Button type="cancel" raised onClick={onCancel}>Cancel</Button>
<FormButtons
submitText={editmode ? 'Update' : 'Create'}
onCancel={onCancel}
/>
</form>
);
}

View File

@ -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 (
<div style={{ position: 'relative', width: '25px', height: '25px', display: 'inline-block' }} >
<IconButton name="add" id="strategies-add" colored title="Sort" onClick={this.stopPropagation}/>
<Menu target="strategies-add" valign="bottom" align="left" ripple onClick={
(e) => this.setSort(e.target.getAttribute('data-target'))}>
<IconButton name="add" id="strategies-add" raised accent title="Add Strategy" onClick={this.stopPropagation}/>
<Menu target="strategies-add" valign="bottom" align="right" ripple style={menuStyle}>
<MenuItem disabled>Add Strategy:</MenuItem>
{this.props.strategies.map((s) => <MenuItem key={s.name} onClick={() => this.addStrategy(s.name)}>{s.name}</MenuItem>)}
{this.props.strategies.map((s) =>
<MenuItem key={s.name} title={s.description} onClick={() => this.addStrategy(s.name)}>{s.name}</MenuItem>)
}
</Menu>
</div>
);

View File

@ -22,16 +22,16 @@ class StrategiesList extends React.Component {
return <i style={{ color: 'red' }}>No strategies added</i>;
}
const blocks = configuredStrategies.map((strat, i) => (
const blocks = configuredStrategies.map((strategy, i) => (
<ConfigureStrategy
key={`${strat.name}-${i}`}
strategy={strat}
key={`${strategy.name}-${i}`}
strategy={strategy}
removeStrategy={this.props.removeStrategy.bind(null, i)}
updateStrategy={this.props.updateStrategy.bind(null, i)}
strategyDefinition={strategies.find(s => s.name === strat.name)} />
strategyDefinition={strategies.find(s => s.name === strategy.name)} />
));
return (
<div>
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
{blocks}
</div>
);

View File

@ -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) => ({

View File

@ -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 <i>Loding available strategies</i>;
return <ProgressBar indeterminate />;
}
return (
<div>
<h5 style={headerStyle}>Activation strategies <AddStrategy {...this.props} /> </h5>
<HeaderTitle title="Activation strategies" actions={<AddStrategy {...this.props} />} />
<StrategiesList {...this.props} />
</div>
);

View File

@ -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 => (
<Textfield
key={field}
name={field}
label={field}
onChange={this.handleConfigChange.bind(this, field)}
value={this.props.strategy.parameters[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 (
<div key={name}>
<StrategyInputPersentage
name={name}
onChange={this.handleConfigChange.bind(this, name)}
value={1 * value} />
{description && <p>{description}</p>}
</div>
);
} else if (type === 'list') {
let list = [];
if (typeof value === 'string') {
list = value
.trim()
.split(',')
.filter(Boolean);
}
return (
<div key={name}>
<StrategyInputList name={name} list={list} setConfig={this.setConfig} />
{description && <p>{description}</p>}
</div>
);
} else if (type === 'number') {
return (
<div key={name}>
<Textfield
pattern="-?[0-9]*(\.[0-9]+)?"
error={`${name} is not a number!`}
floatingLabel
required={required}
style={{ width: '100%' }}
name={name}
label={name}
onChange={this.handleConfigChange.bind(this, name)}
value={value}
/>
{description && <p>{description}</p>}
</div>
);
} else {
return (
<div key={name}>
<Textfield
floatingLabel
rows={2}
style={{ width: '100%' }}
required={required}
name={name}
label={name}
onChange={this.handleConfigChange.bind(this, name)}
value={value}
/>
{description && <p>{description}</p>}
</div>
);
}
});
}
return null;
}
render () {
if (!this.props.strategyDefinition) {
const { name } = this.props.strategy;
return (
<div>
<h6><span style={{ color: 'red' }}>Strategy "{this.props.strategy.name}" deleted</span></h6>
<Button onClick={this.handleRemove} icon="remove" label="remove strategy" flat/>
</div>
<Card shadow={0} style={style}>
<CardTitle>"{name}" deleted?</CardTitle>
<CardText>
The strategy "{name}" does not exist on this server.
<Link to={`/strategies/create?name=${name}`}>Want to create it now?</Link>
</CardText>
<CardActions>
<Button onClick={this.handleRemove} label="remove strategy" accent raised>Remove</Button>
</CardActions>
</Card>
);
}
const inputFields = this.renderInputFields(this.props.strategyDefinition) || [];
const inputFields = this.renderInputFields(this.props.strategyDefinition);
const { name } = this.props.strategy;
return (
<div style={{ padding: '5px 15px', backgroundColor: '#f7f8ff', marginBottom: '10px' }}>
<h6>
<strong>{this.props.strategy.name} </strong>
(<a style={{ color: '#ff4081' }} onClick={this.handleRemove} href="#remove-strat">remove</a>)
</h6>
<small>{this.props.strategyDefinition.description}</small>
<div>
{inputFields}
</div>
</div>
<Card shadow={0} style={style}>
<CardTitle style={{ color: '#fff', height: '65px', background: '#607d8b' }}>
<Icon name="extension" />&nbsp;{ name }
</CardTitle>
<CardText>
{this.props.strategyDefinition.description}
</CardText>
{
inputFields && <CardActions border style={{ padding: '20px' }}>
{inputFields}
</CardActions>
}
<CardMenu style={{ color: '#fff' }}>
<Link
title="View strategy"
to={`/strategies/view/${name}`}
style={{ color: '#fff', display: 'inline-block', verticalAlign: 'bottom', marginRight: '5px' }}>
<Icon name="link" />
</Link>
<IconButton title="Remove strategy from toggle" name="delete" onClick={this.handleRemove} />
</CardMenu>
</Card>
);
}
}

View File

@ -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 (<div>
<p>{name}</p>
{list.map((entryValue, index) => (
<Chip
key={index + entryValue}
style={{ marginRight: '3px' }}
onClose={() => this.onClose(index)}>{entryValue}</Chip>
))}
<div style={{ display: 'flex' }}>
<Textfield
name={`${name}_input`}
style={{ width: '100%', flex: 1 }}
floatingLabel
label="Add list entry"
onFocus={this.onFocus}
onBlur={this.onBlur} />
<IconButton name="add" raised style={{ flex: 1, flexGrow: 0, margin: '20px 0 0 10px' }} onClick={this.setValue} />
</div>
</div>);
}
}

View File

@ -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 }) => (
<div>
<div style={labelStyle}>{name}: {value}%</div>
<Slider min={0} max={100} defaultValue={value} value={value} onChange={onChange} label={name} />
</div>
);

View File

@ -52,7 +52,6 @@ export default class FeatureListComponent extends React.PureComponent {
return (
<div>
<div className={styles.topList}>
<Chip onClick={() => this.toggleMetrics()} className={styles.topListItem0}>
{ settings.showLastHour &&
<ChipContact className="mdl-color--teal mdl-color-text--white">
@ -68,8 +67,7 @@ export default class FeatureListComponent extends React.PureComponent {
</ChipContact> }
{ '1 minute' }
</Chip>
<div className={styles.topListItem2} style={{ margin: '-10px 10px 0 10px' }}>
<Textfield
floatingLabel
@ -79,7 +77,7 @@ export default class FeatureListComponent extends React.PureComponent {
style={{ width: '100%' }}
/>
</div>
<div style={{ position: 'relative' }} className={styles.topListItem0}>
<IconButton name="sort" id="demo-menu-top-right" colored title="Sort" />
<Menu target="demo-menu-top-right" valign="bottom" align="right" ripple onClick={
@ -95,23 +93,20 @@ export default class FeatureListComponent extends React.PureComponent {
</Menu>
</div>
<Link to="/features/create" className={styles.topListItem0}>
<FABButton ripple component="span" mini>
<Icon name="add" />
</FABButton>
<IconButton ripple raised name="add" component="span" style={{ color: 'black' }}/>
</Link>
</div>
<ul className="demo-list-item mdl-list">
{features.map((feature, i) =>
<Feature key={i}
settings={settings}
metricsLastHour={featureMetrics.lastHour[feature.name]}
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
feature={feature}
onFeatureClick={onFeatureClick}
onFeatureRemove={onFeatureRemove}/>
)}
{features.map((feature, i) =>
<Feature key={i}
settings={settings}
metricsLastHour={featureMetrics.lastHour[feature.name]}
metricsLastMinute={featureMetrics.lastMinute[feature.name]}
feature={feature}
onFeatureClick={onFeatureClick}
onFeatureRemove={onFeatureRemove}/>
)}
</ul>
<hr />
<Link to="/features/create" className={styles.topListItem0}>

View File

@ -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 (<div>
<SwitchWithLabel
checked={featureToggle.enabled}
onChange={() => toggleFeature(featureToggle)}>Toggle {featureToggle.name}</SwitchWithLabel>
<hr />
<Grid style={{ textAlign: 'center' }}>
<Cell tablet={4} col={3} phone={12}>
{
lastMinute.isFallback ?
<Icon style={{ width: '100px', height: '100px', fontSize: '100px', color: '#ccc' }}
name="report problem" title="No metrics avaiable" /> :
<div>
<Progress animatePercentageText strokeWidth={10} percentage={lastMinutePercent} width="50" />
</div>
}
<p><strong>Last minute</strong><br /> Yes {lastMinute.yes}, No: {lastMinute.no}</p>
</Cell>
<Cell col={3} tablet={4} phone={12}>
{
lastHour.isFallback ?
<Icon style={{ width: '100px', height: '100px', fontSize: '100px', color: '#ccc' }}
name="report problem" title="No metrics avaiable" /> :
<div>
<Progress strokeWidth={10} percentage={lastHourPercent} width="50" />
</div>
}
<p><strong>Last hour</strong><br /> Yes {lastHour.yes}, No: {lastHour.no}</p>
</Cell>
<Cell col={6} tablet={12}>
{seenApps.length > 0 ?
(<div><strong>Seen in applications:</strong></div>) :
<div>
<Icon style={{ width: '100px', height: '100px', fontSize: '100px', color: '#ccc' }}
name="report problem" title="Not used in a app in the last hour" />
<div><small><strong>Not used in a app in the last hour.</strong>
This might be due to your client implementation is not reporting usage.</small></div>
</div>
}
<AppsLinkList apps={seenApps} />
</Cell>
</Grid>
</div>);
}
}

View File

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

View File

@ -14,4 +14,4 @@
line-height: 25px;
dominant-baseline: middle;
text-anchor: middle;
}
}

View File

@ -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}%</text>
>{this.state.percentageText}%</text>
</svg>);
}
}
@ -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,
};

View File

@ -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 <HistoryComponent toggleName={featureToggleName} />;
} else if (TABS[activeTab] === TABS.edit) {
return <EditFeatureToggle featureToggle={featureToggle} />;
} else {
return <MetricComponent featureToggle={featureToggle} />;
}
}
goToTab (tabName, featureToggleName) {
hashHistory.push(`/features/${tabName}/${featureToggleName}`);
}
render () {
const {
featureToggle,
features,
activeTab,
featureToggleName,
} = this.props;
if (!featureToggle) {
if (features.length === 0 ) {
return <ProgressBar indeterminate />;
}
return (
<span>
Could not find the toggle <Link to={{ pathname: '/features/create', query: { name: featureToggleName } }}>
{featureToggleName}</Link>
</span>
);
}
const activeTabId = TABS[this.props.activeTab] ? TABS[this.props.activeTab] : TABS.view;
const tabContent = this.getTabContent(activeTab);
return (
<div>
<h4>{featureToggle.name} <small>{featureToggle.enabled ? 'is enabled' : 'is disabled'}</small>
<small style={{ float: 'right', lineHeight: '38px' }}>
Created {(new Date(featureToggle.createdAt)).toLocaleString('nb-NO')}
</small>
</h4>
<div>{featureToggle.description}</div>
<Tabs activeTab={activeTabId} ripple style={{ marginBottom: '10px' }}>
<Tab onClick={() => this.goToTab('view', featureToggleName)}>Metrics</Tab>
<Tab onClick={() => this.goToTab('edit', featureToggleName)}>Edit</Tab>
<Tab onClick={() => this.goToTab('history', featureToggleName)}>History</Tab>
</Tabs>
{tabContent}
</div>
);
}
}

View File

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

View File

@ -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 <span>Loading</span>;
}
return <span>Could not find {this.props.featureToggleName}</span>;
}
return (
<div>
<h4>{featureToggle.name} <small>{featureToggle.enabled ? 'is enabled' : 'is disabled'}</small></h4>
<hr />
<div style={{ maxWidth: '200px' }} >
<Switch style={{ cursor: 'pointer' }} onChange={() => toggleFeature(featureToggle)} checked={featureToggle.enabled}>
Toggle {featureToggle.name}
</Switch>
</div>
<hr />
<Grid style={{ textAlign: 'center' }}>
<Cell col={3}>
{
lastMinute.isFallback ?
<Icon style={{ width: '100px', height: '100px', fontSize: '100px', color: '#ccc' }} name="report problem" title="No metrics avaiable" /> :
<div>
<Progress strokeWidth={10} percentage={lastMinutePercent} width="50" />
</div>
}
<p><strong>Last minute</strong><br /> Yes {lastMinute.yes}, No: {lastMinute.no}</p>
</Cell>
<Cell col={3}>
{
lastHour.isFallback ?
<Icon style={{ width: '100px', height: '100px', fontSize: '100px', color: '#ccc' }} name="report problem" title="No metrics avaiable" /> :
<div>
<Progress strokeWidth={10} percentage={lastHourPercent} width="50" />
</div>
}
<p><strong>Last hour</strong><br /> Yes {lastHour.yes}, No: {lastHour.no}</p>
</Cell>
<Cell col={3}>
{seenApps.length > 0 ?
(<div><strong>Seen in applications:</strong></div>) :
<div>
<Icon style={{ width: '100px', height: '100px', fontSize: '100px', color: '#ccc' }} name="report problem" title="Not used in a app in the last hour" />
<div><small><strong>Not used in a app in the last hour.</strong> This might be due to your client implementation is not reporting usage.</small></div>
</div>
}
{seenApps.length > 0 && seenApps.map((appName) => (
<Link key={appName} to={`/applications/${appName}`}>
{appName}
</Link>
))}
<p>add instances count?</p>
</Cell>
<Cell col={3}>
<p>add history</p>
</Cell>
</Grid>
<hr />
<h4>Edit</h4>
<EditFeatureToggle featureToggle={featureToggle} />
</div>
);
}
}
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);

View File

@ -18,10 +18,7 @@ class History extends PureComponent {
}
return (
<div>
<h5>Last 100 changes</h5>
<HistoryList history={history} />
</div>
<HistoryList history={history} title="Last 100 changes" />
);
}
}

View File

@ -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 = (
<div>
<div className={SPADEN_CLASS.D}>- {key}: {JSON.stringify(diff.lhs)}</div>
<div className={KLASSES.D}>- {key}: {JSON.stringify(diff.lhs)}</div>
</div>
);
} else if (diff.rhs !== undefined) {
change = (
<div>
<div className={SPADEN_CLASS.N}>+ {key}: {JSON.stringify(diff.rhs)}</div>
<div className={KLASSES.N}>+ {key}: {JSON.stringify(diff.rhs)}</div>
</div>
);
}
@ -55,12 +44,12 @@ function buildDiff (diff, idx) {
} else if (diff.lhs !== undefined && diff.rhs !== undefined) {
change = (
<div>
<div className={SPADEN_CLASS.D}>- {key}: {JSON.stringify(diff.lhs)}</div>
<div className={SPADEN_CLASS.N}>+ {key}: {JSON.stringify(diff.rhs)}</div>
<div className={KLASSES.D}>- {key}: {JSON.stringify(diff.lhs)}</div>
<div className={KLASSES.N}>+ {key}: {JSON.stringify(diff.rhs)}</div>
</div>
);
} else {
const spadenClass = SPADEN_CLASS[diff.kind];
const spadenClass = KLASSES[diff.kind];
const prefix = DIFF_PREFIXES[diff.kind];
change = (<div className={spadenClass}>{prefix} {key}: {JSON.stringify(diff.rhs || diff.item)}</div>);
@ -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 = <div className={SPADEN_CLASS.N}>{JSON.stringify(logEntry.data, null, 2)}</div>;
changes = <div className={KLASSES.N}>{JSON.stringify(entry.data, null, 2)}</div>;
}
return <code className="smalltext man">{changes.length === 0 ? '(no changes)' : changes}</code>;
}
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 (
<div className={style['history-item']}>
<dl>
<dt>Id:</dt>
<dd>{id}</dd>
<dt>Type:</dt>
<dd>
<Icon name={icon} title={type} style={{ fontSize: '1.6rem' }} />
<span> {type}</span>
</dd>
<dt>Timestamp:</dt>
<dd>{createdAt}</dd>
<dt>Username:</dt>
<dd>{createdBy}</dd>
<dt>Diff</dt>
<dd>{data}</dd>
</dl>
</div>
);
return (<pre style={{ maxWidth: '500px', overflowX: 'auto', overflowY: 'hidden', width: 'auto' }}>
<code className="smalltext man">{changes.length === 0 ? '(no changes)' : changes}</code>
</pre>);
}
}

View File

@ -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) => <HistoryItemJson key={`log${entry.id}`} entry={entry} />);
} else {
entries = history.map((entry) => <HistoryItemDiff key={`log${entry.id}`} entry={entry} />);
entries = (<Table
sortable
rows={
history.map((entry) => Object.assign({
diff: (<HistoryItemDiff entry={entry} />),
}, entry))
}
style={{ width: '100%' }}
>
<TableHeader name="type">Type</TableHeader>
<TableHeader name="createdBy">User</TableHeader>
<TableHeader name="diff">Diff</TableHeader>
<TableHeader numeric name="createdAt" cellFormatter={(v) => (new Date(v)).toLocaleString('nb-NO')}>Time</TableHeader>
</Table>);
}
return (
<div className={style.history}>
<Switch checked={showData} onChange={this.toggleShowDiff.bind(this)}>Show full events</Switch>
{entries}
<HeaderTitle title={this.props.title} actions={
<SwitchWithLabel checked={showData} onChange={this.toggleShowDiff.bind(this)}>Show full events</SwitchWithLabel>
}/>
{entries}
</div>
);
}

View File

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

View File

@ -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 <span>fetching..</span>;
}
const { history, toggleName } = this.props;
return (
<div>
<h5>Showing history for toggle: <strong>{this.props.toggleName}</strong></h5>
<ListComponent history={this.state.history} />
</div>
<ListComponent
history={history}
title={
<span>Showing history for toggle: <Link to={`/features/edit/${toggleName}`}>
<strong>{toggleName}</strong>
</Link>
</span>}/>
);
}
}
export default HistoryListToggle;

View File

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

View File

@ -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) {

View File

@ -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 (
<div>
<h4>{`Total of ${globalCount} toggles`}</h4>
<DataTable
style={{ width: '100%' }}
rows={clientList}
selectable={false}
>
<TableHeader name="name">Instance</TableHeader>
<TableHeader name="appName">Application name</TableHeader>
<TableHeader numeric name="ping" cellFormatter={
(v) => (v.toString())
}>Last seen</TableHeader>
<TableHeader numeric name="count">Counted</TableHeader>
</DataTable>
</div>
);
}
}
export default Metrics;

View File

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

View File

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

View File

@ -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) => (<div>{gerArrayWithEntries(num).map((v, i) => {
const key = `${PARAM_PREFIX}${i + 1}`;
return (
const Parameter = ({ set, input = {}, index }) => (
<div style={{ background: '#f1f1f1', padding: '16px 20px', marginBottom: '20px' }}>
<Textfield
label={`Parameter name ${i + 1}`}
name={key} key={key}
onChange={({ target }) => setValue(key, target.value)}
value={input[key]} />
);
})}</div>);
const AddStrategy = ({
input,
setValue,
incValue,
// clear,
onCancel,
onSubmit,
}) => (
<form onSubmit={onSubmit(input)}>
<section>
<Textfield label="Strategy name"
name="name" required
pattern="^[0-9a-zA-Z\.\-]+$"
onChange={({ target }) => setValue('name', trim(target.value))}
value={input.name}
/>
<br />
<Textfield
rows={2}
label="Description"
name="description"
onChange={({ target }) => setValue('description', target.value)}
value={input.description}
/>
</section>
<section>
{genParams(input, input._params, setValue)}
<IconButton name="add" title="Add parameter" onClick={(e) => {
e.preventDefault();
incValue('_params');
}}/>
</section>
<br />
<hr />
<section>
<Button type="submit" raised primary >Create</Button>
&nbsp;
<Button type="cancel" raised onClick={onCancel}>Cancel</Button>
</section>
</form>
style={{ width: '50%' }}
floatingLabel
label={`Parameter name ${index + 1}`}
onChange={({ target }) => set({ name: target.value }, true)}
value={input.name} />
<div style={{ position: 'relative', display: 'inline-block' }}>
<span className="mdl-outline" id={`${index}-type-menu`} style={{
borderRadius: '2px',
cursor: 'pointer',
boxShadow: '0 2px 2px 0 rgba(0,0,0,.04),0 3px 1px -2px rgba(0,0,0,.1),0 1px 5px 0 rgba(0,0,0,.12)',
marginLeft: '10px',
border: '1px solid #f1f1f1',
backgroundColor: 'white',
padding: '10px 2px 10px 20px',
}}>
{input.type || 'string'}
<IconButton name="arrow_drop_down" onClick={(evt) => evt.preventDefault()} />
</span>
<Menu target={`${index}-type-menu`} align="right">
<MenuItem onClick={() => set({ type: 'string' })}>string</MenuItem>
<MenuItem onClick={() => set({ type: 'percentage' })}>percentage</MenuItem>
<MenuItem onClick={() => set({ type: 'list' })}>list</MenuItem>
<MenuItem onClick={() => set({ type: 'number' })}>number</MenuItem>
</Menu>
</div>
<Textfield
floatingLabel
style={{ width: '100%' }}
rows={2}
label={`Parameter name ${index + 1} description`}
onChange={({ target }) => set({ description: target.value })}
value={input.description}
/>
<Checkbox
label="Required"
checked={!!input.required}
onChange={() => set({ required: !input.required })}
ripple
defaultChecked
/>
</div>
);
AddStrategy.propTypes = {
input: PropTypes.object,
setValue: PropTypes.func,
incValue: PropTypes.func,
clear: PropTypes.func,
onCancel: PropTypes.func,
onSubmit: PropTypes.func,
};
const EditHeader = () => (
<div>
<h4>Edit strategy</h4>
<p style={{ background: '#ffb7b7', padding: '16px 20px' }}>
Be carefull! Changing a strategy definition might also require changes to the
implementation in the clients.
</p>
</div>
);
const CreateHeader = () => (
<div>
<h4>Create a new Strategy definition</h4>
</div>
);
const Parameters = ({ input = [], count = 0, updateInList }) => (
<div>{
gerArrayWithEntries(count)
.map((v, i) => <Parameter
key={i}
set={(v) => updateInList('parameters', i, v, true)}
index={i}
input={input[i]}
/>)
}</div>);
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 (
<form onSubmit={onSubmit(input)}>
{editmode ? <EditHeader /> : <CreateHeader />}
<Textfield label="Strategy name"
floatingLabel
name="name"
required
disabled={editmode}
pattern="^[0-9a-zA-Z\.\-]+$"
onChange={({ target }) => setValue('name', trim(target.value))}
value={input.name}
/>
<br />
<Textfield
floatingLabel
style={{ width: '100%' }}
rows={2}
label="Description"
name="description"
onChange={({ target }) => setValue('description', target.value)}
value={input.description}
/>
<Parameters input={input.parameters} count={input._params} updateInList={updateInList} />
<IconButton raised name="add" title="Add parameter" onClick={(e) => {
e.preventDefault();
incValue('_params');
}}/> &nbsp;Add parameter
<br />
<hr />
<FormButtons
submitText={editmode ? 'Update' : 'Create'}
onCancel={onCancel}
/>
</form>
);
}
}
export default AddStrategy;

View File

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

View File

@ -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 => (
<Chip key={k}><small>{k}</small></Chip>
));
}
render () {
const { strategies, removeStrategy } = this.props;
return (
<div>
<h5>Strategies</h5>
<IconButton name="add" onClick={() => this.context.router.push('/strategies/create')} title="Add new strategy"/>
<hr />
<List>
{strategies.length > 0 ? strategies.map((strategy, i) => {
return (
<ListItem key={i}>
<ListItemContent><strong>{strategy.name}</strong> {strategy.description}</ListItemContent>
<IconButton name="delete" onClick={() => removeStrategy(strategy)} />
</ListItem>
);
}) : <ListItem>No entries</ListItem>}
</List>
<HeaderTitle title="Strategies"
actions={
<IconButton raised
name="add"
onClick={() => this.context.router.push('/strategies/create')}
title="Add new strategy" />} />
<List>
{strategies.length > 0 ? strategies.map((strategy, i) => (
<ListItem key={i} twoLine>
<ListItemContent icon="extension" subtitle={strategy.description}>
<Link to={`/strategies/view/${strategy.name}`}>
<strong>{strategy.name}</strong>
</Link>
</ListItemContent>
<IconButton name="delete" onClick={() => removeStrategy(strategy)} />
</ListItem>
)) : <ListItem>No entries</ListItem>}
</List>
</div>
);
}

View File

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

View File

@ -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) => (
<ListItem twoLine key={`${name}-${i}`} title={required ? 'Required' : ''}>
<ListItemContent avatar={required ? 'add' : ' '} subtitle={description}>
{name} <small>({type})</small>
</ListItemContent>
</ListItem>
));
} else {
return <ListItem>(no params)</ListItem>;
}
}
render () {
const {
strategy,
applications,
toggles,
} = this.props;
const {
parameters = [],
} = strategy;
return (
<div>
<Grid>
<Cell col={12} >
<h6>Parameters</h6>
<hr />
<List>
{this.renderParameters(parameters)}
</List>
</Cell>
<Cell col={6} tablet={12}>
<h6>Applications using this strategy</h6>
<hr />
<AppsLinkList apps={applications} />
</Cell>
<Cell col={6} tablet={12}>
<h6>Toggles using this strategy</h6>
<hr />
<TogglesLinkList toggles={toggles} />
</Cell>
</Grid>
</div>
);
}
}
export default ShowStrategyComponent;

View File

@ -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 <EditStrategy strategy={this.props.strategy} />;
} else {
return (<ShowStrategy
strategy={this.props.strategy}
toggles={this.props.toggles}
applications={this.props.applications} />);
}
}
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 <ProgressBar indeterminate />;
}
const tabContent = this.getTabContent(activeTabId);
return (
<div>
<HeaderTitle title={strategy.name} subtitle={strategy.description} />
<Tabs activeTab={activeTabId} ripple>
<Tab onClick={() => this.goToTab('view')}>Details</Tab>
<Tab onClick={() => this.goToTab('edit')}>Edit</Tab>
</Tabs>
<section>
<div className="content">
{tabContent}
</div>
</section>
</div>
);
}
}

View File

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

View File

@ -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 {
<DialogTitle>Action required</DialogTitle>
<DialogContent>
<p>
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.
</p>
<form onSubmit={this.handleSubmit}>
<Textfield

View File

@ -14,7 +14,26 @@ function fetchApplication (appName) {
.then(response => 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,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(
<Provider store={unleashStore}>
<Router history={hashHistory}>
<Route path="/" component={App}>
<IndexRedirect to="/features" />
<Route pageTitle="Features" path="/features" component={Features} />
<Route pageTitle="Features" path="/features/create" component={CreateFeatureToggle} />
<Route pageTitle="Features" path="/features/edit/:name" component={EditFeatureToggle} />
<Route pageTitle="Strategies" path="/strategies" component={Strategies} />
<Route pageTitle="Strategies" path="/strategies/create" component={CreateStrategies} />
<Route pageTitle="History" path="/history" component={HistoryPage} />
<Route pageTitle="History" path="/history/:toggleName" component={HistoryTogglePage} />
<Route pageTitle="Features" link="/features">
<Route pageTitle="Features" path="/features" component={Features} />
<Route pageTitle="New" path="/features/create" component={CreateFeatureToggle} />
<Route pageTitle=":name" path="/features/:activeTab/:name" component={ViewFeatureToggle} />
</Route>
<Route pageTitle="Strategies" link="/strategies">
<Route pageTitle="Strategies" path="/strategies" component={Strategies} />
<Route pageTitle="New" path="/strategies/create" component={CreateStrategies} />
<Route pageTitle=":strategyName" path="/strategies/:activeTab/:strategyName" component={StrategyView} />
</Route>
<Route pageTitle="History" link="/history">
<Route pageTitle="History" path="/history" component={HistoryPage} />
<Route pageTitle=":toggleName" path="/history/:toggleName" component={HistoryTogglePage} />
</Route>
<Route pageTitle="Archive" path="/archive" component={Archive} />
<Route pageTitle="Applications" path="/applications" component={Applications} />
<Route pageTitle="Applications" path="/applications/:name" component={ApplicationView} />
<Route pageTitle="Client strategies" ppath="/client-strategies" component={ClientStrategies} />
<Route pageTitle="Applications" link="/applications">
<Route pageTitle="Applications" path="/applications" component={Applications} />
<Route pageTitle=":name" path="/applications/:name" component={ApplicationView} />
</Route>
</Route>
</Router>
</Provider>, document.getElementById('app'));

View File

@ -1,6 +1,10 @@
import React from 'react';
import React, { PropTypes } from 'react';
import ApplicationEditComponent from '../../component/application/application-edit-container';
const render = ({ params }) => <ApplicationEditComponent appName={params.name} />;
render.propTypes = {
params: PropTypes.object.isRequired,
};
export default render;

View File

@ -1,11 +0,0 @@
import React from 'react';
import ClientStrategy from '../../component/client-strategy/strategy-container';
const render = () => (
<div>
<h5>Client Strategies</h5>
<ClientStrategy />
</div>
);
export default render;

View File

@ -1,11 +1,7 @@
import React from 'react';
import AddFeatureToggleForm from '../../component/feature/form-add-container';
const render = () => (
<div>
<h6>Create feature toggle</h6>
<AddFeatureToggleForm />
</div>
);
const render = () => (<AddFeatureToggleForm title="Create feature toggle" />);
export default render;

View File

@ -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 (
<EditFeatureToggleForm featureToggleName={this.props.params.name} />
);
}
};

View File

@ -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 (
<ViewFeatureToggle featureToggleName={params.name} activeTab={params.activeTab} />
);
}
};

View File

@ -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 }) => <HistoryListToggle toggleName={params.toggleName} />;

View File

@ -0,0 +1,10 @@
import React, { PropTypes } from 'react';
import ShowStrategy from '../../component/strategies/strategy-details-container';
const render = ({ params }) => <ShowStrategy strategyName={params.strategyName} activeTab={params.activeTab} />;
render.propTypes = {
params: PropTypes.object.isRequired,
};
export default render;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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',
},
],
},