Vault Management UI (round 2) (#4631)

* Add VaultMeta edit dialog

* Updated (WIP)

* Meta & password edit completed

* Added SelectionList component for selections

* Use SelectionList in DappPermisions

* AddDapps uses SelectionList

* Fix AccountCard to consistent height

* Convert Signer defaults to SelectionList

* Subtle selection border

* Display account vault information

* Allow invalid addresses to display icons (e.g. vaults)

* Display vault on edit meta

* Convert VaultAccounts to SelectionList

* Allow editing of Vault in meta

* Add tests for SectionList component

* Add tests for VaultSelector component

* Auto-focus description field (aligns with #4657)

* Apply scroll fixes from lates commit in #4621

* Remove unneeded logs

* Remove extra div, fixing ParityBar overflow

* Disable save if password don't match

* s/disabled/readOnly/

* string -> bool
This commit is contained in:
Jaco Greeff 2017-02-24 18:05:04 +01:00 committed by GitHub
parent 9ff427caaf
commit 570e6f32b0
23 changed files with 1298 additions and 67 deletions

View File

@ -21,9 +21,11 @@ import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { newError } from '~/redux/actions';
import { Button, Form, Input, InputChip, Portal } from '~/ui';
import { Button, Form, Input, InputAddress, InputChip, Portal } from '~/ui';
import { CancelIcon, SaveIcon } from '~/ui/Icons';
import VaultStore from '~/views/Vaults/store';
import VaultSelector from '../VaultSelector';
import Store from './store';
@observer
@ -39,6 +41,11 @@ class EditMeta extends Component {
}
store = new Store(this.context.api, this.props.account);
vaultStore = VaultStore.get(this.context.api);
componentWillMount () {
this.vaultStore.loadVaults();
}
render () {
const { description, name, nameError, tags } = this.store;
@ -55,6 +62,7 @@ class EditMeta extends Component {
/>
}
>
{ this.renderVaultSelector() }
<Form>
<Input
autoFocus
@ -102,6 +110,7 @@ class EditMeta extends Component {
onTokensChange={ this.store.setTags }
tokens={ tags.slice() }
/>
{ this.renderVault() }
</Form>
</Portal>
);
@ -154,22 +163,87 @@ class EditMeta extends Component {
);
}
renderVault () {
const { isAccount, vaultName } = this.store;
if (!isAccount) {
return null;
}
return (
<InputAddress
allowCopy={ false }
allowInvalid
readOnly
hint={
<FormattedMessage
id='editMeta.vault.hint'
defaultMessage='the vault this account is attached to'
/>
}
label={
<FormattedMessage
id='editMeta.vault.label'
defaultMessage='associated vault'
/>
}
onClick={ this.toggleVaultSelector }
value={ vaultName }
/>
);
}
renderVaultSelector () {
const { isAccount, isVaultSelectorOpen, vaultName } = this.store;
if (!isAccount || !isVaultSelectorOpen) {
return null;
}
return (
<VaultSelector
onClose={ this.toggleVaultSelector }
onSelect={ this.setVaultName }
selected={ vaultName }
vaultStore={ this.vaultStore }
/>
);
}
onClose = () => {
this.props.onClose();
}
onSave = () => {
const { address, isAccount, meta, vaultName } = this.store;
if (this.store.hasError) {
return;
}
return this.store
.save()
.then(() => {
if (isAccount && (meta.vault !== vaultName)) {
return this.vaultStore.moveAccount(vaultName, address);
}
return true;
})
.then(this.onClose)
.catch((error) => {
this.props.newError(error);
});
}
setVaultName = (vaultName) => {
this.store.setVaultName(vaultName);
this.toggleVaultSelector();
}
toggleVaultSelector = () => {
this.store.toggleVaultSelector();
}
}
function mapDispatchToProps (dispatch) {

View File

@ -45,7 +45,9 @@ function createApi () {
return {
parity: {
setAccountName: sinon.stub().resolves(),
setAccountMeta: sinon.stub().resolves()
setAccountMeta: sinon.stub().resolves(),
listVaults: sinon.stub().resolves([]),
listOpenedVaults: sinon.stub().resolves([])
}
};
}

View File

@ -21,12 +21,14 @@ import { validateName } from '~/util/validation';
export default class Store {
@observable address = null;
@observable isAccount = false;
@observable isVaultSelectorOpen = false;
@observable description = null;
@observable meta = null;
@observable name = null;
@observable nameError = null;
@observable passwordHint = null;
@observable tags = null;
@observable vaultName = null;
constructor (api, account) {
const { address, name, meta, uuid } = account;
@ -34,14 +36,15 @@ export default class Store {
this._api = api;
transaction(() => {
this.isAccount = !!uuid;
this.address = address;
this.meta = meta || {};
this.name = name || '';
this.isAccount = !!uuid;
this.description = this.meta.description || '';
this.passwordHint = this.meta.passwordHint || '';
this.tags = this.meta.tags && this.meta.tags.peek() || [];
this.vaultName = this.meta.vault;
});
}
@ -74,6 +77,14 @@ export default class Store {
this.tags = tags.slice();
}
@action setVaultName = (vaultName) => {
this.vaultName = vaultName;
}
@action setVaultSelectorOpen = (isOpen) => {
this.isVaultSelectorOpen = isOpen;
}
save () {
const meta = {
description: this.description,
@ -94,4 +105,8 @@ export default class Store {
throw error;
});
}
toggleVaultSelector () {
this.setVaultSelectorOpen(!this.isVaultSelectorOpen);
}
}

View File

@ -142,8 +142,23 @@ describe('modals/EditMeta/Store', () => {
expect(store.tags.peek()).to.deep.equal(['taga', 'tagb']);
});
});
describe('setVaultName', () => {
it('sets the name', () => {
store.setVaultName('testing');
expect(store.vaultName).to.equal('testing');
});
});
describe('setVaultSelectorOpen', () => {
it('sets the state', () => {
store.setVaultSelectorOpen('testing');
expect(store.isVaultSelectorOpen).to.equal('testing');
});
});
});
describe('operations', () => {
describe('save', () => {
beforeEach(() => {
createStore(ACCOUNT);
@ -169,4 +184,12 @@ describe('modals/EditMeta/Store', () => {
}));
});
});
});
describe('toggleVaultSelector', () => {
it('inverts the selector state', () => {
store.toggleVaultSelector();
expect(store.isVaultSelectorOpen).to.be.true;
});
});
});

View File

@ -15,9 +15,8 @@
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
*/
.body {
/* TODO: These styles are shared with CreateAccount - DRY up */
.passwords {
/* TODO: These styles are shared with CreateAccount - DRY up */
.passwords {
display: flex;
flex-wrap: wrap;
@ -34,5 +33,12 @@
padding-left: 0.25rem;
}
}
}
}
.disabled {
opacity: 0.25;
}
.group+.group {
margin-top: 1em;
}

View File

@ -80,7 +80,7 @@ class VaultCreate extends Component {
/>
}
>
<div className={ styles.body }>
<div>
<Input
error={ vaultNameError }
hint={

View File

@ -0,0 +1,17 @@
// Copyright 2015-2017 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/>.
export default from './vaultMeta';

View File

@ -0,0 +1,298 @@
// Copyright 2015-2017 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 { Checkbox } from 'material-ui';
import { observer } from 'mobx-react';
import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { newError } from '~/redux/actions';
import { Button, Form, Input, Portal, VaultCard } from '~/ui';
import PasswordStrength from '~/ui/Form/PasswordStrength';
import { CheckIcon, CloseIcon } from '~/ui/Icons';
import styles from '../VaultCreate/vaultCreate.css';
@observer
class VaultMeta extends Component {
static propTypes = {
newError: PropTypes.func.isRequired,
vaultStore: PropTypes.object.isRequired
};
state = {
passwordEdit: false
};
render () {
const { isBusyMeta, isModalMetaOpen, vault, vaultDescription, vaultPassword, vaultPasswordRepeat, vaultPasswordRepeatError, vaultPasswordOld, vaultPasswordHint } = this.props.vaultStore;
const { passwordEdit } = this.state;
if (!isModalMetaOpen) {
return null;
}
return (
<Portal
busy={ isBusyMeta }
buttons={ [
<Button
disabled={ isBusyMeta }
icon={ <CloseIcon /> }
key='close'
label={
<FormattedMessage
id='vaults.editMeta.button.close'
defaultMessage='close'
/>
}
onClick={ this.onClose }
/>,
<Button
disabled={ isBusyMeta || !!vaultPasswordRepeatError }
icon={ <CheckIcon /> }
key='vault'
label={
<FormattedMessage
id='vaults.editMeta.button.save'
defaultMessage='save'
/>
}
onClick={ this.onExecute }
/>
] }
onClose={ this.onClose }
open
title={
<FormattedMessage
id='vaults.editMeta.title'
defaultMessage='Edit Vault Metadata'
/>
}
>
<VaultCard.Layout vault={ vault }>
<Form>
<div className={ styles.group }>
<Input
autoFocus
hint={
<FormattedMessage
id='vaults.editMeta.description.hint'
defaultMessage='the description for this vault'
/>
}
label={
<FormattedMessage
id='vaults.editMeta.description.label'
defaultMessage='vault description'
/>
}
onChange={ this.onEditDescription }
value={ vaultDescription }
/>
<Input
hint={
<FormattedMessage
id='vaults.editMeta.passwordHint.hint'
defaultMessage='your password hint for this vault'
/>
}
label={
<FormattedMessage
id='vaults.editMeta.passwordHint.label'
defaultMessage='password hint'
/>
}
onChange={ this.onEditPasswordHint }
value={ vaultPasswordHint }
/>
</div>
<div className={ styles.group }>
<Checkbox
label={
<FormattedMessage
id='vaults.editMeta.allowPassword'
defaultMessage='Change vault password'
/>
}
checked={ passwordEdit }
onCheck={ this.onTogglePassword }
/>
<div className={ [styles.passwords, passwordEdit ? null : styles.disabled].join(' ') }>
<div className={ styles.password }>
<Input
disabled={ !passwordEdit }
hint={
<FormattedMessage
id='vaults.editMeta.currentPassword.hint'
defaultMessage='your current vault password'
/>
}
label={
<FormattedMessage
id='vaults.editMeta.currentPassword.label'
defaultMessage='current password'
/>
}
onChange={ this.onEditPasswordCurrent }
type='password'
value={ vaultPasswordOld }
/>
</div>
</div>
<div className={ [styles.passwords, passwordEdit ? null : styles.disabled].join(' ') }>
<div className={ styles.password }>
<Input
disabled={ !passwordEdit }
hint={
<FormattedMessage
id='vaults.editMeta.password.hint'
defaultMessage='a strong, unique password'
/>
}
label={
<FormattedMessage
id='vaults.editMeta.password.label'
defaultMessage='new password'
/>
}
onChange={ this.onEditPassword }
type='password'
value={ vaultPassword }
/>
</div>
<div className={ styles.password }>
<Input
disabled={ !passwordEdit }
error={ vaultPasswordRepeatError }
hint={
<FormattedMessage
id='vaults.editMeta.password2.hint'
defaultMessage='verify your new password'
/>
}
label={
<FormattedMessage
id='vaults.editMeta.password2.label'
defaultMessage='new password (repeat)'
/>
}
onChange={ this.onEditPasswordRepeat }
type='password'
value={ vaultPasswordRepeat }
/>
</div>
</div>
<div className={ passwordEdit ? null : styles.disabled }>
<PasswordStrength input={ vaultPassword } />
</div>
</div>
</Form>
</VaultCard.Layout>
</Portal>
);
// <InputChip
// addOnBlur
// hint={
// <FormattedMessage
// id='vaults.editMeta.tags.hint'
// defaultMessage='press <Enter> to add a tag'
// />
// }
// label={
// <FormattedMessage
// id='vaults.editMeta.tags.label'
// defaultMessage='(optional) tags'
// />
// }
// onTokensChange={ this.onEditTags }
// tokens={ vaultTags.slice() }
// />
}
onEditDescription = (event, description) => {
this.props.vaultStore.setVaultDescription(description);
}
onEditPasswordCurrent = (event, password) => {
this.props.vaultStore.setVaultPasswordOld(password);
}
onEditPassword = (event, password) => {
this.props.vaultStore.setVaultPassword(password);
}
onEditPasswordHint = (event, hint) => {
this.props.vaultStore.setVaultPasswordHint(hint);
}
onEditPasswordRepeat = (event, password) => {
this.props.vaultStore.setVaultPasswordRepeat(password);
}
onEditTags = (tags) => {
this.props.vaultStore.setVaultTags(tags);
}
onTogglePassword = () => {
this.setState({
passwordEdit: !this.state.passwordEdit
});
}
onExecute = () => {
const { vaultPasswordRepeatError } = this.props.vaultStore;
const { passwordEdit } = this.state;
if (vaultPasswordRepeatError) {
return;
}
return Promise
.all([
passwordEdit
? this.props.vaultStore.editVaultPassword()
: true
])
.then(() => {
return this.props.vaultStore.editVaultMeta();
})
.catch(this.props.newError)
.then(this.onClose);
}
onClose = () => {
this.setState({
passwordEdit: false
});
this.props.vaultStore.closeMetaModal();
}
}
function mapDispatchToProps (dispatch) {
return bindActionCreators({
newError
}, dispatch);
}
export default connect(
null,
mapDispatchToProps
)(VaultMeta);

View File

@ -0,0 +1,171 @@
// Copyright 2015-2017 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 VaultMeta from './';
const VAULT = {
name: 'testVault'
};
let component;
let instance;
let reduxStore;
let vaultStore;
function createReduxStore () {
reduxStore = {
dispatch: sinon.stub(),
subscribe: sinon.stub(),
getState: () => {
return {};
}
};
return reduxStore;
}
function createVaultStore () {
vaultStore = {
isBusyMeta: false,
isModalMetaOpen: true,
vault: VAULT,
vaultDescription: '',
vaultTags: [],
vaultName: VAULT.name,
vaults: [VAULT],
closeMetaModal: sinon.stub(),
editVaultMeta: sinon.stub().resolves(true),
editVaultPassword: sinon.stub().resolves(true),
setVaultDescription: sinon.stub(),
setVaultPassword: sinon.stub(),
setVaultPasswordRepeat: sinon.stub(),
setVaultPasswordHint: sinon.stub(),
setVaultPasswordOld: sinon.stub(),
setVaultTags: sinon.stub()
};
return vaultStore;
}
function render (props = {}) {
component = shallow(
<VaultMeta vaultStore={ createVaultStore() } />,
{
context: {
store: createReduxStore()
}
}
).find('VaultMeta').shallow();
instance = component.instance();
return component;
}
describe('modals/VaultMeta', () => {
beforeEach(() => {
render();
});
it('renders defaults', () => {
expect(component).to.be.ok;
});
describe('event methods', () => {
describe('onEditDescription', () => {
beforeEach(() => {
instance.onEditDescription(null, 'testing');
});
it('calls into setVaultDescription', () => {
expect(vaultStore.setVaultDescription).to.have.been.calledWith('testing');
});
});
describe('onEditPassword', () => {
beforeEach(() => {
instance.onEditPassword(null, 'testPassword');
});
it('calls setVaultPassword', () => {
expect(vaultStore.setVaultPassword).to.have.been.calledWith('testPassword');
});
});
describe('onEditPasswordHint', () => {
beforeEach(() => {
instance.onEditPasswordHint(null, 'testPasswordHint');
});
it('calls setVaultPasswordHint', () => {
expect(vaultStore.setVaultPasswordHint).to.have.been.calledWith('testPasswordHint');
});
});
describe('onEditPasswordCurrent', () => {
beforeEach(() => {
instance.onEditPasswordCurrent(null, 'testPasswordOld');
});
it('calls setVaultPasswordHint', () => {
expect(vaultStore.setVaultPasswordOld).to.have.been.calledWith('testPasswordOld');
});
});
describe('onEditPasswordRepeat', () => {
beforeEach(() => {
instance.onEditPasswordRepeat(null, 'testPassword');
});
it('calls setVaultPasswordRepeat', () => {
expect(vaultStore.setVaultPasswordRepeat).to.have.been.calledWith('testPassword');
});
});
describe('onEditTags', () => {
beforeEach(() => {
instance.onEditTags('testing');
});
it('calls into setVaultTags', () => {
expect(vaultStore.setVaultTags).to.have.been.calledWith('testing');
});
});
describe('onClose', () => {
beforeEach(() => {
instance.onClose();
});
it('calls into closeMetaModal', () => {
expect(vaultStore.closeMetaModal).to.have.been.called;
});
});
describe('onExecute', () => {
beforeEach(() => {
return instance.onExecute();
});
it('calls into editVaultMeta', () => {
expect(vaultStore.editVaultMeta).to.have.been.called;
});
});
});
});

View File

@ -0,0 +1,17 @@
// Copyright 2015-2017 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/>.
export default from './vaultSelector';

View File

@ -0,0 +1,99 @@
// Copyright 2015-2017 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 { observer } from 'mobx-react';
import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import { Portal, SelectionList, VaultCard } from '~/ui';
@observer
export default class VaultSelector extends Component {
static propTypes = {
onClose: PropTypes.func.isRequired,
onSelect: PropTypes.func.isRequired,
selected: PropTypes.string,
vaultStore: PropTypes.object.isRequired
};
render () {
return (
<Portal
isChildModal
onClose={ this.onClose }
open
title={
<FormattedMessage
id='vaults.selector.title'
defaultMessage='Select Account Vault'
/>
}
>
{ this.renderList() }
</Portal>
);
}
renderList () {
const { vaults } = this.props.vaultStore;
const openVaults = vaults.filter((vault) => vault.isOpen);
if (openVaults.length === 0) {
return (
<FormattedMessage
id='vaults.selector.noneAvailable'
defaultMessage='There are currently no vaults opened and available for selection. Create and open some first before attempting to select a vault for an account move.'
/>
);
}
return (
<SelectionList
items={ openVaults }
isChecked={ this.isSelected }
noStretch
onSelectClick={ this.onSelect }
renderItem={ this.renderVault }
/>
);
}
renderVault = (vault) => {
return (
<VaultCard
hideAccounts
hideButtons
vault={ vault }
/>
);
}
isSelected = (vault) => {
return this.props.selected === vault.name;
}
onSelect = (vault) => {
this.props.onSelect(
this.props.selected === vault.name
? ''
: vault.name
);
}
onClose = () => {
this.props.onClose();
}
}

View File

@ -0,0 +1,169 @@
// Copyright 2015-2017 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 VaultSelector from './';
const VAULTS_OPENED = [
{ name: 'A', isOpen: true },
{ name: 'B', isOpen: true }
];
const VAULTS_CLOSED = [
{ name: 'C' },
{ name: 'D' }
];
let component;
let instance;
let onClose;
let onSelect;
let vaultStore;
function createVaultStore () {
vaultStore = {
vaults: VAULTS_OPENED.concat(VAULTS_CLOSED)
};
return vaultStore;
}
function render () {
onClose = sinon.stub();
onSelect = sinon.stub();
component = shallow(
<VaultSelector
onClose={ onClose }
onSelect={ onSelect }
selected='firstValue'
vaultStore={ createVaultStore() }
/>
);
instance = component.instance();
return component;
}
describe('ui/VaultSelector', () => {
beforeEach(() => {
render();
});
it('renders defaults', () => {
expect(component).to.be.ok;
});
describe('components', () => {
describe('Portal', () => {
let portal;
beforeEach(() => {
portal = component.find('Portal');
});
it('renders', () => {
expect(portal.get(0)).to.be.ok;
});
it('opens as a child modal', () => {
expect(portal.props().isChildModal).to.be.true;
});
it('passes the instance onClose', () => {
expect(portal.props().onClose).to.equal(instance.onClose);
});
});
describe('SelectionList', () => {
let list;
beforeEach(() => {
list = component.find('SelectionList');
});
it('renders', () => {
expect(list.get(0)).to.be.ok;
});
it('passes the open vaults', () => {
expect(list.props().items).to.deep.equal(VAULTS_OPENED);
});
it('passes internal renderItem', () => {
expect(list.props().renderItem).to.equal(instance.renderVault);
});
it('passes internal isChecked', () => {
expect(list.props().isChecked).to.equal(instance.isSelected);
});
it('passes internal onSelectClick', () => {
expect(list.props().onSelectClick).to.equal(instance.onSelect);
});
});
});
describe('instance methods', () => {
describe('renderVault', () => {
let card;
beforeEach(() => {
card = instance.renderVault({ name: 'testVault' });
});
it('renders VaultCard', () => {
expect(card).to.be.ok;
});
});
describe('isSelected', () => {
it('returns true when vault name matches', () => {
expect(instance.isSelected({ name: 'firstValue' })).to.be.true;
});
it('returns false when vault name does not match', () => {
expect(instance.isSelected({ name: 'testValue' })).to.be.false;
});
});
describe('onSelect', () => {
it('calls into props onSelect', () => {
instance.onSelect({ name: 'testing' });
expect(onSelect).to.have.been.called;
});
it('passes name when new selection made', () => {
instance.onSelect({ name: 'newValue' });
expect(onSelect).to.have.been.calledWith('newValue');
});
it('passes empty name when current selection made', () => {
instance.onSelect({ name: 'firstValue' });
expect(onSelect).to.have.been.calledWith('');
});
});
describe('onClose', () => {
it('calls props onClose', () => {
instance.onClose();
expect(onClose).to.have.been.called;
});
});
});
});

View File

@ -34,6 +34,8 @@ export UpgradeParity from './UpgradeParity';
export VaultAccounts from './VaultAccounts';
export VaultCreate from './VaultCreate';
export VaultLock from './VaultLock';
export VaultMeta from './VaultMeta';
export VaultSelector from './VaultSelector';
export VaultUnlock from './VaultUnlock';
export Verification from './Verification';
export WalletSettings from './WalletSettings';

View File

@ -31,6 +31,7 @@ class InputAddress extends Component {
accountsInfo: PropTypes.object,
allowCopy: PropTypes.bool,
autoFocus: PropTypes.bool,
allowInvalid: PropTypes.bool,
className: PropTypes.string,
disabled: PropTypes.bool,
error: PropTypes.string,
@ -112,9 +113,9 @@ class InputAddress extends Component {
}
renderIcon () {
const { value, disabled, label, allowCopy, hideUnderline, readOnly } = this.props;
const { allowInvalid, value, disabled, label, allowCopy, hideUnderline, readOnly } = this.props;
if (!value || !value.length || !util.isAddressValid(value)) {
if (!value || !value.length || (!util.isAddressValid(value) && !allowInvalid)) {
return null;
}

View File

@ -23,12 +23,13 @@ import styles from './layout.css';
export default class Layout extends Component {
static propTypes = {
children: PropTypes.node,
vault: PropTypes.object.isRequired,
withBorder: PropTypes.bool
};
render () {
const { vault, withBorder } = this.props;
const { children, vault, withBorder } = this.props;
const { isOpen, meta, name } = vault;
return (
@ -59,6 +60,7 @@ export default class Layout extends Component {
byline={ meta.description }
title={ name }
/>
{ children }
</div>
</div>
);

View File

@ -30,13 +30,16 @@ export default class VaultCard extends Component {
static propTypes = {
accounts: PropTypes.array,
buttons: PropTypes.array,
children: PropTypes.node,
hideAccounts: PropTypes.bool,
hideButtons: PropTypes.bool,
vault: PropTypes.object.isRequired
};
static Layout = Layout;
render () {
const { buttons, vault } = this.props;
const { children, vault } = this.props;
const { isOpen } = vault;
return (
@ -48,26 +51,20 @@ export default class VaultCard extends Component {
: null
}
>
<div className={ styles.buttons }>
<Button
className={ styles.status }
disabled
icon={
isOpen
? <UnlockedIcon />
: <LockedIcon />
}
key='status'
/>
{ buttons }
</div>
<Layout vault={ vault } />
{ this.renderButtons() }
<Layout vault={ vault }>
{ children }
</Layout>
</Container>
);
}
renderAccounts () {
const { accounts } = this.props;
const { accounts, hideAccounts } = this.props;
if (hideAccounts) {
return null;
}
if (!accounts || !accounts.length) {
return (
@ -101,4 +98,29 @@ export default class VaultCard extends Component {
</div>
);
}
renderButtons () {
const { buttons, hideButtons, vault } = this.props;
const { isOpen } = vault;
if (hideButtons) {
return null;
}
return (
<div className={ styles.buttons }>
<Button
className={ styles.status }
disabled
icon={
isOpen
? <UnlockedIcon />
: <LockedIcon />
}
key='status'
/>
{ buttons }
</div>
);
}
}

View File

@ -36,6 +36,7 @@
.addressline,
.infoline,
.uuidline,
.vault,
.title {
margin-left: 72px;
}
@ -59,6 +60,15 @@
display: inline-block;
}
.vault {
line-height: 32px;
.text {
display: inline-block;
opacity: 0.25;
}
}
.addressline {
display: flex;
}

View File

@ -65,6 +65,7 @@ export default class Header extends Component {
<CopyToClipboard data={ address } />
<div className={ styles.address }>{ address }</div>
</div>
{ this.renderVault() }
{ this.renderUuid() }
<div className={ styles.infoline }>
{ meta.description }
@ -154,4 +155,25 @@ export default class Header extends Component {
</div>
);
}
renderVault () {
const { account } = this.props;
const { meta } = account;
if (!meta || !meta.vault) {
return null;
}
return (
<div className={ styles.vault }>
<IdentityIcon
address={ meta.vault }
inline
/>
<div className={ styles.text }>
{ meta.vault }
</div>
</div>
);
}
}

View File

@ -27,10 +27,12 @@ export default class Store {
@observable isBusyCreate = false;
@observable isBusyLoad = false;
@observable isBusyLock = false;
@observable isBusyMeta = false;
@observable isBusyUnlock = false;
@observable isModalAccountsOpen = false;
@observable isModalCreateOpen = false;
@observable isModalLockOpen = false;
@observable isModalMetaOpen = false;
@observable isModalUnlockOpen = false;
@observable selectedAccounts = {};
@observable vault = null;
@ -41,7 +43,9 @@ export default class Store {
@observable vaultDescription = '';
@observable vaultPassword = '';
@observable vaultPasswordHint = '';
@observable vaultPasswordOld = '';
@observable vaultPasswordRepeat = '';
@observable vaultTags = [];
constructor (api) {
this._api = api;
@ -59,7 +63,9 @@ export default class Store {
this.setVaultDescription('');
this.setVaultPassword('');
this.setVaultPasswordHint('');
this.setVaultPasswordOld('');
this.setVaultPasswordRepeat('');
this.setVaultTags([]);
});
}
@ -79,6 +85,10 @@ export default class Store {
this.isBusyLock = isBusy;
}
@action setBusyMeta = (isBusy) => {
this.isBusyMeta = isBusy;
}
@action setBusyUnlock = (isBusy) => {
this.isBusyUnlock = isBusy;
}
@ -104,6 +114,13 @@ export default class Store {
});
}
@action setModalMetaOpen = (isOpen) => {
transaction(() => {
this.setBusyMeta(false);
this.isModalMetaOpen = isOpen;
});
}
@action setModalUnlockOpen = (isOpen) => {
transaction(() => {
this.setBusyUnlock(false);
@ -161,10 +178,18 @@ export default class Store {
this.vaultPasswordHint = hint;
}
@action setVaultPasswordOld = (password) => {
this.vaultPasswordOld = password;
}
@action setVaultPasswordRepeat = (password) => {
this.vaultPasswordRepeat = password;
}
@action setVaultTags = (tags) => {
this.vaultTags = tags;
}
@action toggleSelectedAccount = (address) => {
this.setSelectedAccounts(Object.assign({}, this.selectedAccounts, {
[address]: !this.selectedAccounts[address] })
@ -183,6 +208,10 @@ export default class Store {
this.setModalLockOpen(false);
}
closeMetaModal () {
this.setModalMetaOpen(false);
}
closeUnlockModal () {
this.setModalUnlockOpen(false);
}
@ -209,6 +238,20 @@ export default class Store {
});
}
openMetaModal (name) {
transaction(() => {
this.clearVaultFields();
this.setVaultName(name);
if (this.vault && this.vault.meta) {
this.setVaultDescription(this.vault.meta.description);
this.setVaultPasswordHint(this.vault.meta.passwordHint);
}
this.setModalMetaOpen(true);
});
}
openUnlockModal (name) {
transaction(() => {
this.setVaultName(name);
@ -268,7 +311,8 @@ export default class Store {
.then(() => {
return this._api.parity.setVaultMeta(this.vaultName, {
description: this.vaultDescription,
passwordHint: this.vaultPasswordHint
passwordHint: this.vaultPasswordHint,
tags: this.vaultTags
});
})
.then(this.loadVaults)
@ -282,6 +326,48 @@ export default class Store {
});
}
editVaultMeta () {
this.setBusyMeta(true);
return this._api.parity
.setVaultMeta(this.vaultName, {
description: this.vaultDescription,
passwordHint: this.vaultPasswordHint,
tags: this.vaultTags
})
.then(this.loadVaults)
.then(() => {
this.setBusyMeta(false);
})
.catch((error) => {
console.error('editVaultMeta', error);
this.setBusyMeta(false);
throw error;
});
}
editVaultPassword () {
this.setBusyMeta(true);
return this._api.parity
.closeVault(this.vaultName)
.then(() => {
return this._api.parity.openVault(this.vaultName, this.vaultPasswordOld);
})
.then(() => {
return this._api.parity.changeVaultPassword(this.vaultName, this.vaultPassword);
})
.then(() => {
this.setBusyMeta(false);
})
.catch((error) => {
console.error('editVaultPassword', error);
this.loadVaults();
this.setBusyMeta(false);
throw new Error('Unable to change the vault password');
});
}
openVault () {
this.setBusyUnlock(true);
@ -307,8 +393,28 @@ export default class Store {
outAccounts.map((address) => this._api.parity.changeVault(address, ''))
])
.then(this.loadVaults)
.then(() => {
this.setBusyAccounts(false);
})
.catch((error) => {
console.error('moveAccounts', error);
this.setBusyAccounts(false);
throw error;
});
}
moveAccount (vaultName, address) {
this.setBusyAccounts(true);
return this._api.parity
.changeVault(address, vaultName)
.then(this.loadVaults)
.then(() => {
this.setBusyAccounts(false);
})
.catch((error) => {
console.error('moveAccount', error);
this.setBusyAccounts(false);
throw error;
});
}

View File

@ -44,6 +44,8 @@ describe('modals/Vaults/Store', () => {
store.setVaultPassword('blah');
store.setVaultPasswordRepeat('bleh');
store.setVaultPasswordHint('hint');
store.setVaultPasswordOld('old');
store.setVaultTags('tags');
store.clearVaultFields();
});
@ -55,6 +57,8 @@ describe('modals/Vaults/Store', () => {
expect(store.vaultPassword).to.equal('');
expect(store.vaultPasswordRepeat).to.equal('');
expect(store.vaultPasswordHint).to.equal('');
expect(store.vaultPasswordOld).to.equal('');
expect(store.vaultTags.length).to.equal(0);
});
});
@ -90,6 +94,14 @@ describe('modals/Vaults/Store', () => {
});
});
describe('setBusyMeta', () => {
it('sets the flag', () => {
store.setBusyMeta('busy');
expect(store.isBusyMeta).to.equal('busy');
});
});
describe('setBusyUnlock', () => {
it('sets the flag', () => {
store.setBusyUnlock('busy');
@ -122,6 +134,14 @@ describe('modals/Vaults/Store', () => {
});
});
describe('setModalMetaOpen', () => {
it('sets the flag', () => {
store.setModalMetaOpen('opened');
expect(store.isModalMetaOpen).to.equal('opened');
});
});
describe('setModalUnlockOpen', () => {
beforeEach(() => {
store.setVaultPassword('testing');
@ -233,6 +253,14 @@ describe('modals/Vaults/Store', () => {
});
});
describe('setVaultTags', () => {
it('sets the tags', () => {
store.setVaultTags('test');
expect(store.vaultTags).to.equal('test');
});
});
describe('toggleSelectedAccount', () => {
beforeEach(() => {
store.toggleSelectedAccount('123');
@ -301,6 +329,17 @@ describe('modals/Vaults/Store', () => {
});
});
describe('closeMetaModal', () => {
beforeEach(() => {
store.setModalMetaOpen(true);
store.closeMetaModal();
});
it('sets the opened state to false', () => {
expect(store.isModalMetaOpen).to.be.false;
});
});
describe('closeUnlockModal', () => {
beforeEach(() => {
store.setModalUnlockOpen(true);
@ -364,6 +403,20 @@ describe('modals/Vaults/Store', () => {
});
});
describe('openMetaModal', () => {
beforeEach(() => {
store.openMetaModal('testing');
});
it('sets the opened state to true', () => {
expect(store.isModalMetaOpen).to.be.true;
});
it('stores the name', () => {
expect(store.vaultName).to.equal('testing');
});
});
describe('openUnlockModal', () => {
beforeEach(() => {
store.openUnlockModal('testing');
@ -443,6 +496,7 @@ describe('modals/Vaults/Store', () => {
store.setVaultPassword('testCreatePassword');
store.setVaultPasswordRepeat('testCreatePassword');
store.setVaultPasswordHint('testCreateHint');
store.setVaultTags('testTags');
return store.createVault();
});
@ -463,11 +517,71 @@ describe('modals/Vaults/Store', () => {
it('calls into parity_setVaultMeta', () => {
expect(api.parity.setVaultMeta).to.have.been.calledWith('testCreateName', {
description: 'testDescription',
passwordHint: 'testCreateHint'
passwordHint: 'testCreateHint',
tags: 'testTags'
});
});
});
describe('editVaultMeta', () => {
beforeEach(() => {
sinon.spy(store, 'setBusyMeta');
store.setVaultDescription('testDescription');
store.setVaultName('testCreateName');
store.setVaultPasswordHint('testCreateHint');
store.setVaultTags('testTags');
return store.editVaultMeta();
});
afterEach(() => {
store.setBusyMeta.restore();
});
it('sets and resets the busy flag', () => {
expect(store.setBusyMeta).to.have.been.calledWith(true);
expect(store.isBusyMeta).to.be.false;
});
it('calls into parity_setVaultMeta', () => {
expect(api.parity.setVaultMeta).to.have.been.calledWith('testCreateName', {
description: 'testDescription',
passwordHint: 'testCreateHint',
tags: 'testTags'
});
});
});
describe('editVaultPassword', () => {
beforeEach(() => {
sinon.spy(store, 'setBusyMeta');
store.setVaultName('testName');
store.setVaultPasswordOld('oldPassword');
store.setVaultPassword('newPassword');
return store.editVaultPassword();
});
afterEach(() => {
store.setBusyMeta.restore();
});
it('sets and resets the busy flag', () => {
expect(store.setBusyMeta).to.have.been.calledWith(true);
expect(store.isBusyMeta).to.be.false;
});
it('calls into parity_openVault', () => {
expect(api.parity.openVault).to.have.been.calledWith('testName', 'oldPassword');
});
it('calls into parity_changeVaultPassword', () => {
expect(api.parity.changeVaultPassword).to.have.been.calledWith('testName', 'newPassword');
});
});
describe('openVault', () => {
beforeEach(() => {
sinon.spy(store, 'setBusyUnlock');
@ -512,5 +626,25 @@ describe('modals/Vaults/Store', () => {
expect(api.parity.changeVault).to.have.been.calledWith('C', '');
});
});
describe('moveAccount', () => {
beforeEach(() => {
sinon.spy(store, 'setBusyAccounts');
return store.moveAccount('testVault', 'A');
});
afterEach(() => {
store.setBusyAccounts.restore();
});
it('sets the busy flag', () => {
expect(store.setBusyAccounts).to.have.been.calledWith(true);
});
it('calls into parity_changeVault', () => {
expect(api.parity.changeVault).to.have.been.calledWith('A', 'testVault');
});
});
});
});

View File

@ -19,9 +19,9 @@ import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { VaultAccounts, VaultCreate, VaultLock, VaultUnlock } from '~/modals';
import { VaultAccounts, VaultCreate, VaultLock, VaultMeta, VaultUnlock } from '~/modals';
import { Button, Container, Page, SectionList, VaultCard } from '~/ui';
import { AccountsIcon, AddIcon, LockedIcon, UnlockedIcon } from '~/ui/Icons';
import { AccountsIcon, AddIcon, EditIcon, LockedIcon, UnlockedIcon } from '~/ui/Icons';
import Store from './store';
import styles from './vaults.css';
@ -70,6 +70,7 @@ class Vaults extends Component {
<VaultAccounts vaultStore={ this.vaultStore } />
<VaultCreate vaultStore={ this.vaultStore } />
<VaultLock vaultStore={ this.vaultStore } />
<VaultMeta vaultStore={ this.vaultStore } />
<VaultUnlock vaultStore={ this.vaultStore } />
{ this.renderList() }
</Page>
@ -109,6 +110,10 @@ class Vaults extends Component {
this.onOpenAccounts(name);
return false;
};
const onClickEdit = () => {
this.onOpenEdit(name);
return false;
};
const onClickOpen = () => {
isOpen
? this.onOpenLockVault(name)
@ -133,13 +138,24 @@ class Vaults extends Component {
}
onClick={ onClickAccounts }
/>,
<Button
icon={ <EditIcon /> }
key='edit'
label={
<FormattedMessage
id='vaults.button.edit'
defaultMessage='edit'
/>
}
onClick={ onClickEdit }
/>,
<Button
icon={ <LockedIcon /> }
key='close'
label={
<FormattedMessage
id='vaults.button.close'
defaultMessage='close vault'
defaultMessage='close'
/>
}
onClick={ onClickOpen }
@ -152,7 +168,7 @@ class Vaults extends Component {
label={
<FormattedMessage
id='vaults.button.open'
defaultMessage='open vault'
defaultMessage='open'
/>
}
onClick={ onClickOpen }
@ -172,10 +188,18 @@ class Vaults extends Component {
this.vaultStore.openCreateModal();
}
onOpenEdit = (name) => {
this.vaultStore.openMetaModal(name);
}
onOpenLockVault = (name) => {
this.vaultStore.openLockModal(name);
}
onOpenMeta = (name) => {
this.vaultStore.openMetaModal(name);
}
onOpenUnlockVault = (name) => {
this.vaultStore.openUnlockModal(name);
}

View File

@ -42,7 +42,7 @@ function render (props = {}) {
return component;
}
describe('modals/Vaults', () => {
describe('views/Vaults', () => {
beforeEach(() => {
render();
});
@ -150,6 +150,22 @@ describe('modals/Vaults', () => {
});
});
describe('onOpenEdit', () => {
beforeEach(() => {
sinon.spy(instance.vaultStore, 'openMetaModal');
instance.onOpenEdit('testing');
});
afterEach(() => {
instance.vaultStore.openMetaModal.restore();
});
it('calls into vaultStore.openMetaModal', () => {
expect(instance.vaultStore.openMetaModal).to.have.been.calledWith('testing');
});
});
describe('onOpenLockVault', () => {
beforeEach(() => {
sinon.spy(instance.vaultStore, 'openLockModal');

View File

@ -63,7 +63,8 @@ export function createApi () {
getVaultMeta: sinon.stub().resolves(TEST_VAULTS_META),
newVault: sinon.stub().resolves(true),
openVault: sinon.stub().resolves(true),
setVaultMeta: sinon.stub().resolves(true)
setVaultMeta: sinon.stub().resolves(true),
changeVaultPassword: sinon.stub().resolves(true)
}
};
}