Feature selector (#4074)

* WIP

* ParityBar verification

* import from index.js

* i18n expansion & tests

* Features component

* Adapt language selector to use features

* Add features to settings view

* typo

* Convert logging

* Fix earlier merge issues resulting in test failures

* Lint failure fixes (new rules)

* Fix additional listing rules

* Re-add FormattedMessage (missing after merge), fix tests

* Fix loader overrides

* grumble: split item rendering (& test)

* grumble: allow enable/disable while testing (default on)

* grumble: move LanguageSelector below Features

* grumble: don't pass visiblity prop (& update tests)

* grumble: missing observable (onClick misbehaving)

* grumble: don't reset to defaults per session

* Fix to single store instance
This commit is contained in:
Jaco Greeff
2017-01-24 17:20:10 +01:00
committed by GitHub
parent 5b2dd8deb2
commit 155bbc328f
24 changed files with 1319 additions and 175 deletions

View File

@@ -16,17 +16,18 @@
import React, { Component, PropTypes } from 'react';
import ReactDOM from 'react-dom';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router';
import { connect } from 'react-redux';
import { throttle } from 'lodash';
import store from 'store';
import imagesEthcoreBlock from '~/../assets/images/parity-logo-white-no-text.svg';
import { CancelIcon, FingerprintIcon } from '~/ui/Icons';
import { Badge, Button, ContainerTitle, ParityBackground } from '~/ui';
import { Embedded as Signer } from '../Signer';
import DappsStore from '~/views/Dapps/dappsStore';
import imagesEthcoreBlock from '!url-loader!../../../assets/images/parity-logo-white-no-text.svg';
import styles from './parityBar.css';
const LS_STORE_KEY = '_parity::parityBar';
@@ -112,10 +113,6 @@ class ParityBar extends Component {
render () {
const { moving, opened, position } = this.state;
const content = opened
? this.renderExpanded()
: this.renderBar();
const containerClassNames = opened
? [ styles.overlay ]
: [ styles.bar ];
@@ -124,11 +121,12 @@ class ParityBar extends Component {
containerClassNames.push(styles.moving);
}
const parityBgClassName = opened
? styles.expanded
: styles.corner;
const parityBgClassNames = [ parityBgClassName, styles.parityBg ];
const parityBgClassNames = [
opened
? styles.expanded
: styles.corner,
styles.parityBg
];
if (moving) {
parityBgClassNames.push(styles.moving);
@@ -169,7 +167,11 @@ class ParityBar extends Component {
ref='container'
style={ parityBgStyle }
>
{ content }
{
opened
? this.renderExpanded()
: this.renderBar()
}
</ParityBackground>
</div>
);
@@ -182,34 +184,38 @@ class ParityBar extends Component {
return null;
}
const parityIcon = (
<img
src={ imagesEthcoreBlock }
className={ styles.parityIcon }
/>
);
const parityButton = (
<Button
className={ styles.parityButton }
icon={ parityIcon }
label={ this.renderLabel('Parity') }
/>
);
return (
<div
className={ styles.cornercolor }
ref={ this.onRef }
>
{ this.renderLink(parityButton) }
{
this.renderLink(
<Button
className={ styles.parityButton }
icon={
<img
className={ styles.parityIcon }
src={ imagesEthcoreBlock }
/>
}
label={
this.renderLabel(
<FormattedMessage
id='parityBar.label.parity'
defaultMessage='Parity'
/>
)
}
/>
)
}
<Button
className={ styles.button }
icon={ <FingerprintIcon /> }
label={ this.renderSignerLabel() }
onClick={ this.toggleDisplay }
/>
{ this.renderDrag() }
</div>
);
@@ -307,7 +313,13 @@ class ParityBar extends Component {
);
}
return this.renderLabel('Signer', bubble);
return this.renderLabel(
<FormattedMessage
id='parityBar.label.signer'
defaultMessage='Signer'
/>,
bubble
);
}
getHorizontal (x) {

View File

@@ -0,0 +1,157 @@
// Copyright 2015, 2016 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import { shallow } from 'enzyme';
import React from 'react';
import sinon from 'sinon';
import ParityBar from './';
let component;
let instance;
let store;
function createRedux (state = {}) {
store = {
dispatch: sinon.stub(),
subscribe: sinon.stub(),
getState: () => Object.assign({ signer: { pending: [] } }, state)
};
return store;
}
function render (props = {}, state = {}) {
component = shallow(
<ParityBar { ...props } />,
{
context: {
store: createRedux(state)
}
}
).find('ParityBar').shallow({ context: { api: {} } });
instance = component.instance();
return component;
}
describe('views/ParityBar', () => {
beforeEach(() => {
render({ dapp: true });
});
it('renders defaults', () => {
expect(component).to.be.ok;
});
it('includes the ParityBackground', () => {
expect(component.find('Connect(ParityBackground)')).to.have.length(1);
});
describe('renderBar', () => {
let bar;
beforeEach(() => {
bar = shallow(instance.renderBar());
});
it('renders nothing when not overlaying a dapp', () => {
render({ dapp: false });
expect(instance.renderBar()).to.be.null;
});
it('renders when overlaying a dapp', () => {
expect(bar.find('div')).not.to.have.length(0);
});
it('renders the Parity button', () => {
const label = shallow(bar.find('Button').first().props().label);
expect(label.find('FormattedMessage').props().id).to.equal('parityBar.label.parity');
});
it('renders the Signer button', () => {
const label = shallow(bar.find('Button').last().props().label);
expect(label.find('FormattedMessage').props().id).to.equal('parityBar.label.signer');
});
});
describe('renderExpanded', () => {
let expanded;
beforeEach(() => {
expanded = shallow(instance.renderExpanded());
});
it('includes the Signer', () => {
expect(expanded.find('Connect(Embedded)')).to.have.length(1);
});
});
describe('renderLabel', () => {
it('renders the label name', () => {
expect(shallow(instance.renderLabel('testing', null)).text()).to.equal('testing');
});
it('renders name and bubble', () => {
expect(shallow(instance.renderLabel('testing', '(bubble)')).text()).to.equal('testing(bubble)');
});
});
describe('renderSignerLabel', () => {
let label;
beforeEach(() => {
label = shallow(instance.renderSignerLabel());
});
it('renders the signer label', () => {
expect(label.find('FormattedMessage').props().id).to.equal('parityBar.label.signer');
});
it('does not render a badge when no pending requests', () => {
expect(label.find('Badge')).to.have.length(0);
});
it('renders a badge when pending requests', () => {
render({}, { signer: { pending: ['123', '456'] } });
expect(shallow(instance.renderSignerLabel()).find('Badge').props().value).to.equal(2);
});
});
describe('opened state', () => {
beforeEach(() => {
sinon.spy(instance, 'renderBar');
sinon.spy(instance, 'renderExpanded');
});
afterEach(() => {
instance.renderBar.restore();
instance.renderExpanded.restore();
});
it('renders the bar on with opened === false', () => {
expect(component.find('Link[to="/apps"]')).to.have.length(1);
});
it('renders expanded with opened === true', () => {
expect(instance.renderExpanded).not.to.have.been.called;
instance.setState({ opened: true });
expect(instance.renderExpanded).to.have.been.called;
});
});
});

View File

@@ -14,67 +14,28 @@
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import { MenuItem } from 'material-ui';
import { observer } from 'mobx-react';
import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import { MenuItem } from 'material-ui';
import LogLevel from 'loglevel';
import { LOG_KEYS } from '~/config';
import { Select, Container, LanguageSelector } from '~/ui';
import Features, { FeaturesStore, FEATURES } from '~/ui/Features';
import Store, { LOGLEVEL_OPTIONS } from './store';
import layout from '../layout.css';
@observer
export default class Parity extends Component {
static contextTypes = {
api: PropTypes.object.isRequired
};
state = {
loglevels: {},
mode: 'active',
selectValues: []
};
store = new Store(this.context.api);
features = FeaturesStore.get();
componentWillMount () {
this.loadMode();
this.loadLogLevels();
this.setSelectValues();
}
loadLogLevels () {
if (process.env.NODE_ENV === 'production') {
return null;
}
const nextState = { ...this.state.logLevels };
Object.keys(LOG_KEYS).map((logKey) => {
const log = LOG_KEYS[logKey];
const logger = LogLevel.getLogger(log.key);
const level = logger.getLevel();
nextState[logKey] = { level, log };
});
this.setState({ logLevels: nextState });
}
setSelectValues () {
if (process.env.NODE_ENV === 'production') {
return null;
}
const selectValues = Object.keys(LogLevel.levels).map((levelName) => {
const value = LogLevel.levels[levelName];
return {
name: levelName,
value
};
});
this.setState({ selectValues });
return this.store.loadMode();
}
render () {
@@ -94,18 +55,30 @@ export default class Parity extends Component {
</div>
</div>
<div className={ layout.details }>
<LanguageSelector />
{ this.renderModes() }
<Features />
<LanguageSelector />
</div>
</div>
{ this.renderLogsConfig() }
</Container>
);
}
renderItem (mode, label) {
return (
<MenuItem
key={ mode }
label={ label }
value={ mode }
>
{ label }
</MenuItem>
);
}
renderLogsConfig () {
if (process.env.NODE_ENV === 'production') {
if (!this.features.active[FEATURES.LOGLEVELS]) {
return null;
}
@@ -127,129 +100,89 @@ export default class Parity extends Component {
}
renderLogsLevels () {
if (process.env.NODE_ENV === 'production') {
return null;
}
const { logLevels } = this.store;
const { logLevels, selectValues } = this.state;
return Object
.keys(logLevels)
.map((key) => {
const { level, log } = logLevels[key];
const { path, desc } = log;
return Object.keys(logLevels).map((logKey) => {
const { level, log } = logLevels[logKey];
const { key, desc } = log;
const onChange = (_, index) => {
this.store.updateLoggerLevel(path, Object.values(LOGLEVEL_OPTIONS)[index].value);
};
const onChange = (_, index) => {
const nextLevel = Object.values(selectValues)[index].value;
LogLevel.getLogger(key).setLevel(nextLevel);
this.loadLogLevels();
};
return (
<div key={ logKey }>
<p>{ desc }</p>
<Select
onChange={ onChange }
value={ level }
values={ selectValues }
/>
</div>
);
});
return (
<div key={ key }>
<p>{ desc }</p>
<Select
onChange={ onChange }
value={ level }
values={ LOGLEVEL_OPTIONS }
/>
</div>
);
});
}
renderModes () {
const { mode } = this.state;
const renderItem = (mode, label) => {
return (
<MenuItem
key={ mode }
value={ mode }
label={ label }
>
{ label }
</MenuItem>
);
};
const { mode } = this.store;
return (
<Select
label={
<FormattedMessage
id='settings.parity.modes.label'
defaultMessage='mode of operation'
/>
}
id='parityModeSelect'
hint={
<FormattedMessage
id='settings.parity.modes.hint'
defaultMessage='the syning mode for the Parity node'
/>
}
value={ mode }
label={
<FormattedMessage
id='settings.parity.modes.label'
defaultMessage='mode of operation'
/>
}
onChange={ this.onChangeMode }
value={ mode }
>
{
renderItem('active',
this.renderItem('active', (
<FormattedMessage
id='settings.parity.modes.mode_active'
defaultMessage='Parity continuously syncs the chain'
/>
)
))
}
{
renderItem('passive',
this.renderItem('passive', (
<FormattedMessage
id='settings.parity.modes.mode_passive'
defaultMessage='Parity syncs initially, then sleeps and wakes regularly to resync'
/>
)
))
}
{
renderItem('dark',
this.renderItem('dark', (
<FormattedMessage
id='settings.parity.modes.mode_dark'
defaultMessage='Parity syncs only when the RPC is active'
/>
)
))
}
{
renderItem('offline',
this.renderItem('offline', (
<FormattedMessage
id='settings.parity.modes.mode_offline'
defaultMessage="Parity doesn't sync"
/>
)
))
}
</Select>
);
}
onChangeMode = (event, index, mode) => {
const { api } = this.context;
api.parity
.setMode(mode)
.then((result) => {
if (result) {
this.setState({ mode });
}
})
.catch((error) => {
console.warn('onChangeMode', error);
});
}
loadMode () {
const { api } = this.context;
api.parity
.mode()
.then((mode) => {
this.setState({ mode });
})
.catch((error) => {
console.warn('loadMode', error);
});
this.store.changeMode(mode || event.target.value);
}
}

View File

@@ -0,0 +1,98 @@
// Copyright 2015, 2016 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import { shallow } from 'enzyme';
import React from 'react';
import sinon from 'sinon';
import { createApi } from './parity.test.js';
import Parity from './';
let component;
let instance;
function render (props = {}) {
component = shallow(
<Parity { ...props } />,
{ context: { api: createApi() } }
);
instance = component.instance();
return component;
}
describe('views/Settings/Parity', () => {
beforeEach(() => {
render();
sinon.spy(instance.store, 'loadMode');
});
afterEach(() => {
instance.store.loadMode.restore();
});
it('renders defaults', () => {
expect(component).to.be.ok;
});
describe('componentWillMount', () => {
beforeEach(() => {
return instance.componentWillMount();
});
it('loads the mode in the store', () => {
expect(instance.store.loadMode).to.have.been.called;
});
});
describe('components', () => {
it('renders a Container component', () => {
expect(component.find('Container')).to.have.length(1);
});
it('renders a LanguageSelector component', () => {
expect(component.find('LanguageSelector')).to.have.length(1);
});
it('renders a Features component', () => {
expect(component.find('LanguageSelector')).to.have.length(1);
});
});
describe('Parity features', () => {
describe('mode selector', () => {
let select;
beforeEach(() => {
select = component.find('Select[id="parityModeSelect"]');
sinon.spy(instance.store, 'changeMode');
});
afterEach(() => {
instance.store.changeMode.restore();
});
it('renders a mode selector', () => {
expect(select).to.have.length(1);
});
it('changes the mode on the store when changed', () => {
select.simulate('change', { target: { value: 'dark' } });
expect(instance.store.changeMode).to.have.been.calledWith('dark');
});
});
});
});

View File

@@ -0,0 +1,30 @@
// Copyright 2015, 2016 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import sinon from 'sinon';
function createApi () {
return {
parity: {
mode: sinon.stub().resolves('passive'),
setMode: sinon.stub().resolves(true)
}
};
}
export {
createApi
};

View File

@@ -0,0 +1,105 @@
// Copyright 2015, 2016 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import LogLevel from 'loglevel';
import { action, observable } from 'mobx';
import { LOG_KEYS } from '~/config';
const DEFAULT_MODE = 'active';
const LOGLEVEL_OPTIONS = Object
.keys(LogLevel.levels)
.map((name) => {
return {
name,
value: LogLevel.levels[name]
};
});
export default class Store {
@observable logLevels = {};
@observable mode = DEFAULT_MODE;
constructor (api) {
this._api = api;
this.loadLogLevels();
}
@action setLogLevels = (logLevels) => {
this.logLevels = logLevels;
}
@action setLogLevelsSelect = (logLevelsSelect) => {
this.logLevelsSelect = logLevelsSelect;
}
@action setMode = (mode) => {
this.mode = mode;
}
changeMode (mode) {
return this._api.parity
.setMode(mode)
.then((result) => {
if (result) {
this.setMode(mode);
}
})
.catch((error) => {
console.warn('changeMode', error);
});
}
loadLogLevels () {
this.setLogLevels(
Object
.keys(LOG_KEYS)
.reduce((state, logKey) => {
const log = LOG_KEYS[logKey];
const logger = LogLevel.getLogger(log.key);
const level = logger.getLevel();
state[logKey] = {
level,
log
};
return state;
}, this.logLevels)
);
}
updateLoggerLevel (path, level) {
LogLevel.getLogger(path).setLevel(level);
this.loadLogLevels();
}
loadMode () {
return this._api.parity
.mode()
.then((mode) => {
this.setMode(mode);
})
.catch((error) => {
console.warn('loadMode', error);
});
}
}
export {
LOGLEVEL_OPTIONS
};

View File

@@ -0,0 +1,84 @@
// Copyright 2015, 2016 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import sinon from 'sinon';
import { createApi } from './parity.test.js';
import Store from './store';
let api;
let store;
function createStore () {
api = createApi();
store = new Store(api);
return store;
}
describe('views/Settings/Parity/Store', () => {
beforeEach(() => {
createStore();
sinon.spy(store, 'setMode');
});
afterEach(() => {
store.setMode.restore();
});
it('defaults to mode === active', () => {
expect(store.mode).to.equal('active');
});
describe('@action', () => {
describe('setMode', () => {
it('sets the mode', () => {
store.setMode('offline');
expect(store.mode).to.equal('offline');
});
});
});
describe('operations', () => {
describe('changeMode', () => {
beforeEach(() => {
return store.changeMode('offline');
});
it('calls parity.setMode', () => {
expect(api.parity.setMode).to.have.been.calledWith('offline');
});
it('sets the mode as provided', () => {
expect(store.setMode).to.have.been.calledWith('offline');
});
});
describe('loadMode', () => {
beforeEach(() => {
return store.loadMode();
});
it('calls parity.mode', () => {
expect(api.parity.mode).to.have.been.called;
});
it('sets the mode as retrieved', () => {
expect(store.setMode).to.have.been.calledWith('passive');
});
});
});
});