UI component updates (#4010)
* Update blockStatus & test results * IdentityIcon rendering tests for #3950 * Update IdentityName with external messages * Expand to cover basic layout sections * ConfirmDialog rendering tests * TxHash expansion & tests * Cleanup ui/*.spec.js PropType warnings * Use react-intl plural for confirmation/confirmations (verified manually)
This commit is contained in:
parent
602a4429cc
commit
ddeb06d9cc
@ -21,7 +21,7 @@ const TEST_ENV = process.env.NODE_ENV === 'test';
|
|||||||
|
|
||||||
export function createIdentityImg (address, scale = 8) {
|
export function createIdentityImg (address, scale = 8) {
|
||||||
return TEST_ENV
|
return TEST_ENV
|
||||||
? ''
|
? 'test-createIdentityImg'
|
||||||
: blockies({
|
: blockies({
|
||||||
seed: (address || '').toLowerCase(),
|
seed: (address || '').toLowerCase(),
|
||||||
size: 8,
|
size: 8,
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
|
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import React, { Component, PropTypes } from 'react';
|
import React, { Component, PropTypes } from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { bindActionCreators } from 'redux';
|
import { bindActionCreators } from 'redux';
|
||||||
|
|
||||||
@ -39,7 +40,12 @@ class BlockStatus extends Component {
|
|||||||
if (!syncing) {
|
if (!syncing) {
|
||||||
return (
|
return (
|
||||||
<div className={ styles.blockNumber }>
|
<div className={ styles.blockNumber }>
|
||||||
{ blockNumber.toFormat() } best block
|
<FormattedMessage
|
||||||
|
id='ui.blockStatus.bestBlock'
|
||||||
|
defaultMessage='{blockNumber} best block'
|
||||||
|
values={ {
|
||||||
|
blockNumber: blockNumber.toFormat()
|
||||||
|
} } />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -47,26 +53,45 @@ class BlockStatus extends Component {
|
|||||||
if (syncing.warpChunksAmount && syncing.warpChunksProcessed && !syncing.warpChunksAmount.eq(syncing.warpChunksProcessed)) {
|
if (syncing.warpChunksAmount && syncing.warpChunksProcessed && !syncing.warpChunksAmount.eq(syncing.warpChunksProcessed)) {
|
||||||
return (
|
return (
|
||||||
<div className={ styles.syncStatus }>
|
<div className={ styles.syncStatus }>
|
||||||
{ syncing.warpChunksProcessed.mul(100).div(syncing.warpChunksAmount).toFormat(2) }% warp restore
|
<FormattedMessage
|
||||||
|
id='ui.blockStatus.warpRestore'
|
||||||
|
defaultMessage='{percentage}% warp restore'
|
||||||
|
values={ {
|
||||||
|
percentage: syncing.warpChunksProcessed.mul(100).div(syncing.warpChunksAmount).toFormat(2)
|
||||||
|
} } />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let syncStatus = null;
|
||||||
let warpStatus = null;
|
let warpStatus = null;
|
||||||
|
|
||||||
|
if (syncing.currentBlock && syncing.highestBlock) {
|
||||||
|
syncStatus = (
|
||||||
|
<span>
|
||||||
|
<FormattedMessage
|
||||||
|
id='ui.blockStatus.syncStatus'
|
||||||
|
defaultMessage='{currentBlock}/{highestBlock} syncing'
|
||||||
|
values={ {
|
||||||
|
currentBlock: syncing.currentBlock.toFormat(),
|
||||||
|
highestBlock: syncing.highestBlock.toFormat()
|
||||||
|
} } />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (syncing.blockGap) {
|
if (syncing.blockGap) {
|
||||||
const [first, last] = syncing.blockGap;
|
const [first, last] = syncing.blockGap;
|
||||||
|
|
||||||
warpStatus = (
|
warpStatus = (
|
||||||
<span>, { first.mul(100).div(last).toFormat(2) }% historic</span>
|
<span>
|
||||||
);
|
<FormattedMessage
|
||||||
}
|
id='ui.blockStatus.warpStatus'
|
||||||
|
defaultMessage=', {percentage}% historic'
|
||||||
let syncStatus = null;
|
values={ {
|
||||||
|
percentage: first.mul(100).div(last).toFormat(2)
|
||||||
if (syncing && syncing.currentBlock && syncing.highestBlock) {
|
} } />
|
||||||
syncStatus = (
|
</span>
|
||||||
<span>{ syncing.currentBlock.toFormat() }/{ syncing.highestBlock.toFormat() } syncing</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
94
js/src/ui/BlockStatus/blockStatus.spec.js
Normal file
94
js/src/ui/BlockStatus/blockStatus.spec.js
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
// 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 BigNumber from 'bignumber.js';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import React from 'react';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
|
||||||
|
import BlockStatus from './';
|
||||||
|
|
||||||
|
let component;
|
||||||
|
|
||||||
|
function createRedux (syncing = false, blockNumber = new BigNumber(123)) {
|
||||||
|
return {
|
||||||
|
dispatch: sinon.stub(),
|
||||||
|
subscribe: sinon.stub(),
|
||||||
|
getState: () => {
|
||||||
|
return {
|
||||||
|
nodeStatus: {
|
||||||
|
blockNumber,
|
||||||
|
syncing
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function render (reduxStore = createRedux(), props) {
|
||||||
|
component = shallow(
|
||||||
|
<BlockStatus { ...props } />,
|
||||||
|
{ context: { store: reduxStore } }
|
||||||
|
).find('BlockStatus').shallow();
|
||||||
|
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ui/BlockStatus', () => {
|
||||||
|
it('renders defaults', () => {
|
||||||
|
expect(render()).to.be.ok;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders null with no blockNumber', () => {
|
||||||
|
expect(render(createRedux(false, null)).find('div')).to.have.length(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders only the best block when syncing === false', () => {
|
||||||
|
const messages = render().find('FormattedMessage');
|
||||||
|
|
||||||
|
expect(messages).to.have.length(1);
|
||||||
|
expect(messages).to.have.id('ui.blockStatus.bestBlock');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders only the warp restore status when restoring', () => {
|
||||||
|
const messages = render(createRedux({
|
||||||
|
warpChunksAmount: new BigNumber(100),
|
||||||
|
warpChunksProcessed: new BigNumber(5)
|
||||||
|
})).find('FormattedMessage');
|
||||||
|
|
||||||
|
expect(messages).to.have.length(1);
|
||||||
|
expect(messages).to.have.id('ui.blockStatus.warpRestore');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the current/highest when syncing', () => {
|
||||||
|
const messages = render(createRedux({
|
||||||
|
currentBlock: new BigNumber(123),
|
||||||
|
highestBlock: new BigNumber(456)
|
||||||
|
})).find('FormattedMessage');
|
||||||
|
|
||||||
|
expect(messages).to.have.length(1);
|
||||||
|
expect(messages).to.have.id('ui.blockStatus.syncStatus');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders warp blockGap when catching up', () => {
|
||||||
|
const messages = render(createRedux({
|
||||||
|
blockGap: [new BigNumber(123), new BigNumber(456)]
|
||||||
|
})).find('FormattedMessage');
|
||||||
|
|
||||||
|
expect(messages).to.have.length(1);
|
||||||
|
expect(messages).to.have.id('ui.blockStatus.warpStatus');
|
||||||
|
});
|
||||||
|
});
|
@ -19,7 +19,11 @@ import { shallow } from 'enzyme';
|
|||||||
|
|
||||||
import Button from './button';
|
import Button from './button';
|
||||||
|
|
||||||
function renderShallow (props) {
|
function render (props = {}) {
|
||||||
|
if (props && props.label === undefined) {
|
||||||
|
props.label = 'test';
|
||||||
|
}
|
||||||
|
|
||||||
return shallow(
|
return shallow(
|
||||||
<Button { ...props } />
|
<Button { ...props } />
|
||||||
);
|
);
|
||||||
@ -28,11 +32,11 @@ function renderShallow (props) {
|
|||||||
describe('ui/Button', () => {
|
describe('ui/Button', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders defaults', () => {
|
it('renders defaults', () => {
|
||||||
expect(renderShallow()).to.be.ok;
|
expect(render()).to.be.ok;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders with the specified className', () => {
|
it('renders with the specified className', () => {
|
||||||
expect(renderShallow({ className: 'testClass' })).to.have.className('testClass');
|
expect(render({ className: 'testClass' })).to.have.className('testClass');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -15,16 +15,27 @@
|
|||||||
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
|
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import React, { Component, PropTypes } from 'react';
|
import React, { Component, PropTypes } from 'react';
|
||||||
import ActionDone from 'material-ui/svg-icons/action/done';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import ContentClear from 'material-ui/svg-icons/content/clear';
|
|
||||||
|
|
||||||
import { nodeOrStringProptype } from '~/util/proptypes';
|
import { nodeOrStringProptype } from '~/util/proptypes';
|
||||||
|
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import Modal from '../Modal';
|
import Modal from '../Modal';
|
||||||
|
import { CancelIcon, CheckIcon } from '../Icons';
|
||||||
|
|
||||||
import styles from './confirmDialog.css';
|
import styles from './confirmDialog.css';
|
||||||
|
|
||||||
|
const DEFAULT_NO = (
|
||||||
|
<FormattedMessage
|
||||||
|
id='ui.confirmDialog.no'
|
||||||
|
defaultMessage='no' />
|
||||||
|
);
|
||||||
|
const DEFAULT_YES = (
|
||||||
|
<FormattedMessage
|
||||||
|
id='ui.confirmDialog.yes'
|
||||||
|
defaultMessage='yes' />
|
||||||
|
);
|
||||||
|
|
||||||
export default class ConfirmDialog extends Component {
|
export default class ConfirmDialog extends Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
children: PropTypes.node.isRequired,
|
children: PropTypes.node.isRequired,
|
||||||
@ -33,10 +44,10 @@ export default class ConfirmDialog extends Component {
|
|||||||
iconDeny: PropTypes.node,
|
iconDeny: PropTypes.node,
|
||||||
labelConfirm: PropTypes.string,
|
labelConfirm: PropTypes.string,
|
||||||
labelDeny: PropTypes.string,
|
labelDeny: PropTypes.string,
|
||||||
title: nodeOrStringProptype().isRequired,
|
|
||||||
visible: PropTypes.bool.isRequired,
|
|
||||||
onConfirm: PropTypes.func.isRequired,
|
onConfirm: PropTypes.func.isRequired,
|
||||||
onDeny: PropTypes.func.isRequired
|
onDeny: PropTypes.func.isRequired,
|
||||||
|
title: nodeOrStringProptype().isRequired,
|
||||||
|
visible: PropTypes.bool.isRequired
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
@ -60,12 +71,12 @@ export default class ConfirmDialog extends Component {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
<Button
|
<Button
|
||||||
label={ labelDeny || 'no' }
|
icon={ iconDeny || <CancelIcon /> }
|
||||||
icon={ iconDeny || <ContentClear /> }
|
label={ labelDeny || DEFAULT_NO }
|
||||||
onClick={ onDeny } />,
|
onClick={ onDeny } />,
|
||||||
<Button
|
<Button
|
||||||
label={ labelConfirm || 'yes' }
|
icon={ iconConfirm || <CheckIcon /> }
|
||||||
icon={ iconConfirm || <ActionDone /> }
|
label={ labelConfirm || DEFAULT_YES }
|
||||||
onClick={ onConfirm } />
|
onClick={ onConfirm } />
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
157
js/src/ui/ConfirmDialog/confirmDialog.spec.js
Normal file
157
js/src/ui/ConfirmDialog/confirmDialog.spec.js
Normal 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, { PropTypes } from 'react';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
|
||||||
|
import muiTheme from '../Theme';
|
||||||
|
|
||||||
|
import ConfirmDialog from './';
|
||||||
|
|
||||||
|
let component;
|
||||||
|
let instance;
|
||||||
|
let onConfirm;
|
||||||
|
let onDeny;
|
||||||
|
|
||||||
|
function createRedux () {
|
||||||
|
return {
|
||||||
|
dispatch: sinon.stub(),
|
||||||
|
subscribe: sinon.stub(),
|
||||||
|
getState: () => {
|
||||||
|
return {
|
||||||
|
settings: {
|
||||||
|
backgroundSeed: 'xyz'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function render (props = {}) {
|
||||||
|
onConfirm = sinon.stub();
|
||||||
|
onDeny = sinon.stub();
|
||||||
|
|
||||||
|
if (props.visible === undefined) {
|
||||||
|
props.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseComponent = shallow(
|
||||||
|
<ConfirmDialog
|
||||||
|
{ ...props }
|
||||||
|
title='test title'
|
||||||
|
onConfirm={ onConfirm }
|
||||||
|
onDeny={ onDeny }>
|
||||||
|
<div id='testContent'>
|
||||||
|
some test content
|
||||||
|
</div>
|
||||||
|
</ConfirmDialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
instance = baseComponent.instance();
|
||||||
|
component = baseComponent.find('Connect(Modal)').shallow({
|
||||||
|
childContextTypes: {
|
||||||
|
muiTheme: PropTypes.object,
|
||||||
|
store: PropTypes.object
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
muiTheme,
|
||||||
|
store: createRedux()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ui/ConfirmDialog', () => {
|
||||||
|
it('renders defaults', () => {
|
||||||
|
expect(render()).to.be.ok;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the body as provided', () => {
|
||||||
|
expect(render().find('div[id="testContent"]').text()).to.equal('some test content');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('properties', () => {
|
||||||
|
let props;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
props = render().props();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the actions', () => {
|
||||||
|
expect(props.actions).to.deep.equal(instance.renderActions());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes title', () => {
|
||||||
|
expect(props.title).to.equal('test title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes visiblity flag', () => {
|
||||||
|
expect(props.visible).to.be.true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderActions', () => {
|
||||||
|
describe('defaults', () => {
|
||||||
|
let buttons;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
render();
|
||||||
|
buttons = instance.renderActions();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with supplied onConfim/onDeny callbacks', () => {
|
||||||
|
expect(buttons[0].props.onClick).to.deep.equal(onDeny);
|
||||||
|
expect(buttons[1].props.onClick).to.deep.equal(onConfirm);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders default labels', () => {
|
||||||
|
expect(buttons[0].props.label.props.id).to.equal('ui.confirmDialog.no');
|
||||||
|
expect(buttons[1].props.label.props.id).to.equal('ui.confirmDialog.yes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders default icons', () => {
|
||||||
|
expect(buttons[0].props.icon.type.displayName).to.equal('ContentClear');
|
||||||
|
expect(buttons[1].props.icon.type.displayName).to.equal('NavigationCheck');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('overrides', () => {
|
||||||
|
let buttons;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
render({
|
||||||
|
labelConfirm: 'labelConfirm',
|
||||||
|
labelDeny: 'labelDeny',
|
||||||
|
iconConfirm: 'iconConfirm',
|
||||||
|
iconDeny: 'iconDeny'
|
||||||
|
});
|
||||||
|
buttons = instance.renderActions();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders supplied labels', () => {
|
||||||
|
expect(buttons[0].props.label).to.equal('labelDeny');
|
||||||
|
expect(buttons[1].props.label).to.equal('labelConfirm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders supplied icons', () => {
|
||||||
|
expect(buttons[0].props.icon).to.equal('iconDeny');
|
||||||
|
expect(buttons[1].props.icon).to.equal('iconConfirm');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -19,7 +19,7 @@ import { shallow } from 'enzyme';
|
|||||||
|
|
||||||
import Container from './container';
|
import Container from './container';
|
||||||
|
|
||||||
function renderShallow (props) {
|
function render (props) {
|
||||||
return shallow(
|
return shallow(
|
||||||
<Container { ...props } />
|
<Container { ...props } />
|
||||||
);
|
);
|
||||||
@ -28,11 +28,24 @@ function renderShallow (props) {
|
|||||||
describe('ui/Container', () => {
|
describe('ui/Container', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders defaults', () => {
|
it('renders defaults', () => {
|
||||||
expect(renderShallow()).to.be.ok;
|
expect(render()).to.be.ok;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders with the specified className', () => {
|
it('renders with the specified className', () => {
|
||||||
expect(renderShallow({ className: 'testClass' })).to.have.className('testClass');
|
expect(render({ className: 'testClass' })).to.have.className('testClass');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sections', () => {
|
||||||
|
it('renders the Card', () => {
|
||||||
|
expect(render().find('Card')).to.have.length(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the Title', () => {
|
||||||
|
const title = render({ title: 'title' }).find('Title');
|
||||||
|
|
||||||
|
expect(title).to.have.length(1);
|
||||||
|
expect(title.props().title).to.equal('title');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -29,6 +29,7 @@ const api = {
|
|||||||
|
|
||||||
const store = {
|
const store = {
|
||||||
estimated: '123',
|
estimated: '123',
|
||||||
|
histogram: {},
|
||||||
priceDefault: '456',
|
priceDefault: '456',
|
||||||
totalValue: '789',
|
totalValue: '789',
|
||||||
setGas: sinon.stub(),
|
setGas: sinon.stub(),
|
||||||
|
@ -34,8 +34,8 @@ class IdentityIcon extends Component {
|
|||||||
button: PropTypes.bool,
|
button: PropTypes.bool,
|
||||||
center: PropTypes.bool,
|
center: PropTypes.bool,
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
inline: PropTypes.bool,
|
|
||||||
images: PropTypes.object.isRequired,
|
images: PropTypes.object.isRequired,
|
||||||
|
inline: PropTypes.bool,
|
||||||
padded: PropTypes.bool,
|
padded: PropTypes.bool,
|
||||||
tiny: PropTypes.bool
|
tiny: PropTypes.bool
|
||||||
}
|
}
|
||||||
|
120
js/src/ui/IdentityIcon/identityIcon.spec.js
Normal file
120
js/src/ui/IdentityIcon/identityIcon.spec.js
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
// 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 { mount } from 'enzyme';
|
||||||
|
import React, { PropTypes } from 'react';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
|
||||||
|
import muiTheme from '../Theme';
|
||||||
|
|
||||||
|
import IdentityIcon from './';
|
||||||
|
|
||||||
|
const ADDRESS0 = '0x0000000000000000000000000000000000000000';
|
||||||
|
const ADDRESS1 = '0x0123456789012345678901234567890123456789';
|
||||||
|
const ADDRESS2 = '0x9876543210987654321098765432109876543210';
|
||||||
|
|
||||||
|
let component;
|
||||||
|
|
||||||
|
function createApi () {
|
||||||
|
return {
|
||||||
|
dappsUrl: 'dappsUrl/'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRedux () {
|
||||||
|
return {
|
||||||
|
dispatch: sinon.stub(),
|
||||||
|
subscribe: sinon.stub(),
|
||||||
|
getState: () => {
|
||||||
|
return {
|
||||||
|
images: {
|
||||||
|
[ADDRESS2]: 'reduxImage'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function render (props = {}) {
|
||||||
|
if (props && props.address === undefined) {
|
||||||
|
props.address = ADDRESS1;
|
||||||
|
}
|
||||||
|
|
||||||
|
component = mount(
|
||||||
|
<IdentityIcon { ...props } />,
|
||||||
|
{
|
||||||
|
childContextTypes: {
|
||||||
|
api: PropTypes.object,
|
||||||
|
muiTheme: PropTypes.object
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
api: createApi(),
|
||||||
|
muiTheme,
|
||||||
|
store: createRedux()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ui/IdentityIcon', () => {
|
||||||
|
it('renders defaults', () => {
|
||||||
|
expect(render()).to.be.ok;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('images', () => {
|
||||||
|
it('renders an <img> with address specified', () => {
|
||||||
|
const img = render().find('img');
|
||||||
|
|
||||||
|
expect(img).to.have.length(1);
|
||||||
|
expect(img.props().src).to.equal('test-createIdentityImg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an <img> with redux source when available', () => {
|
||||||
|
const img = render({ address: ADDRESS2 }).find('img');
|
||||||
|
|
||||||
|
expect(img).to.have.length(1);
|
||||||
|
expect(img.props().src).to.equal('dappsUrl/reduxImage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an <ContractIcon> with no address specified', () => {
|
||||||
|
expect(render({ address: null }).find('ActionCode')).to.have.length(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an <CancelIcon> with 0x00..00 address specified', () => {
|
||||||
|
expect(render({ address: ADDRESS0 }).find('ContentClear')).to.have.length(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sizes', () => {
|
||||||
|
it('renders 56px by default', () => {
|
||||||
|
expect(render().find('img').props().width).to.equal('56px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders 16px for tiny', () => {
|
||||||
|
expect(render({ tiny: true }).find('img').props().width).to.equal('16px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders 24px for button', () => {
|
||||||
|
expect(render({ button: true }).find('img').props().width).to.equal('24px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders 32px for inline', () => {
|
||||||
|
expect(render({ inline: true }).find('img').props().width).to.equal('32px');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -15,13 +15,23 @@
|
|||||||
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
|
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import React, { Component, PropTypes } from 'react';
|
import React, { Component, PropTypes } from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { bindActionCreators } from 'redux';
|
import { bindActionCreators } from 'redux';
|
||||||
|
|
||||||
import { isNullAddress } from '~/util/validation';
|
import { isNullAddress } from '~/util/validation';
|
||||||
import ShortenedHash from '../ShortenedHash';
|
import ShortenedHash from '../ShortenedHash';
|
||||||
|
|
||||||
const defaultName = 'UNNAMED';
|
const defaultName = (
|
||||||
|
<FormattedMessage
|
||||||
|
id='ui.identityName.unnamed'
|
||||||
|
defaultMessage='UNNAMED' />
|
||||||
|
);
|
||||||
|
const defaultNameNull = (
|
||||||
|
<FormattedMessage
|
||||||
|
id='ui.identityName.null'
|
||||||
|
defaultMessage='NULL' />
|
||||||
|
);
|
||||||
|
|
||||||
class IdentityName extends Component {
|
class IdentityName extends Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
@ -43,7 +53,7 @@ class IdentityName extends Component {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nullName = isNullAddress(address) ? 'null' : null;
|
const nullName = isNullAddress(address) ? defaultNameNull : null;
|
||||||
const addressFallback = nullName || (shorten ? (<ShortenedHash data={ address } />) : address);
|
const addressFallback = nullName || (shorten ? (<ShortenedHash data={ address } />) : address);
|
||||||
const fallback = unknown ? defaultName : addressFallback;
|
const fallback = unknown ? defaultName : addressFallback;
|
||||||
const isUuid = account && account.name === account.uuid;
|
const isUuid = account && account.name === account.uuid;
|
||||||
|
@ -14,8 +14,10 @@
|
|||||||
// You should have received a copy of the GNU General Public License
|
// You should have received a copy of the GNU General Public License
|
||||||
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
|
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { mount } from 'enzyme';
|
import { mount } from 'enzyme';
|
||||||
|
import React from 'react';
|
||||||
|
import { IntlProvider } from 'react-intl';
|
||||||
|
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
|
|
||||||
import IdentityName from './identityName';
|
import IdentityName from './identityName';
|
||||||
@ -44,9 +46,11 @@ const STORE = {
|
|||||||
|
|
||||||
function render (props) {
|
function render (props) {
|
||||||
return mount(
|
return mount(
|
||||||
<IdentityName
|
<IntlProvider locale='en'>
|
||||||
store={ STORE }
|
<IdentityName
|
||||||
{ ...props } />
|
store={ STORE }
|
||||||
|
{ ...props } />
|
||||||
|
</IntlProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,7 +78,7 @@ describe('ui/IdentityName', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders 0x000...000 as null', () => {
|
it('renders 0x000...000 as null', () => {
|
||||||
expect(render({ address: ADDR_NULL }).text()).to.equal('null');
|
expect(render({ address: ADDR_NULL }).text()).to.equal('NULL');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
import BigNumber from 'bignumber.js';
|
import BigNumber from 'bignumber.js';
|
||||||
import React, { Component, PropTypes } from 'react';
|
import React, { Component, PropTypes } from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { bindActionCreators } from 'redux';
|
import { bindActionCreators } from 'redux';
|
||||||
import { LinearProgress } from 'material-ui';
|
import { LinearProgress } from 'material-ui';
|
||||||
@ -33,8 +34,8 @@ class TxHash extends Component {
|
|||||||
static propTypes = {
|
static propTypes = {
|
||||||
hash: PropTypes.string.isRequired,
|
hash: PropTypes.string.isRequired,
|
||||||
isTest: PropTypes.bool,
|
isTest: PropTypes.bool,
|
||||||
summary: PropTypes.bool,
|
maxConfirmations: PropTypes.number,
|
||||||
maxConfirmations: PropTypes.number
|
summary: PropTypes.bool
|
||||||
}
|
}
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
@ -43,14 +44,14 @@ class TxHash extends Component {
|
|||||||
|
|
||||||
state = {
|
state = {
|
||||||
blockNumber: new BigNumber(0),
|
blockNumber: new BigNumber(0),
|
||||||
transaction: null,
|
subscriptionId: null,
|
||||||
subscriptionId: null
|
transaction: null
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
const { api } = this.context;
|
const { api } = this.context;
|
||||||
|
|
||||||
api.subscribe('eth_blockNumber', this.onBlockNumber).then((subscriptionId) => {
|
return api.subscribe('eth_blockNumber', this.onBlockNumber).then((subscriptionId) => {
|
||||||
this.setState({ subscriptionId });
|
this.setState({ subscriptionId });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -59,28 +60,28 @@ class TxHash extends Component {
|
|||||||
const { api } = this.context;
|
const { api } = this.context;
|
||||||
const { subscriptionId } = this.state;
|
const { subscriptionId } = this.state;
|
||||||
|
|
||||||
api.unsubscribe(subscriptionId);
|
return api.unsubscribe(subscriptionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { hash, isTest, summary } = this.props;
|
const { hash, isTest, summary } = this.props;
|
||||||
|
|
||||||
const link = (
|
const hashLink = (
|
||||||
<a href={ txLink(hash, isTest) } target='_blank'>
|
<a href={ txLink(hash, isTest) } target='_blank'>
|
||||||
<ShortenedHash data={ hash } />
|
<ShortenedHash data={ hash } />
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
||||||
let header = (
|
|
||||||
<p>The transaction has been posted to the network, with a hash of { link }.</p>
|
|
||||||
);
|
|
||||||
if (summary) {
|
|
||||||
header = (<p>{ link }</p>);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{ header }
|
<p>{
|
||||||
|
summary
|
||||||
|
? hashLink
|
||||||
|
: <FormattedMessage
|
||||||
|
id='ui.txHash.posted'
|
||||||
|
defaultMessage='The transaction has been posted to the network with a hash of {hashLink}'
|
||||||
|
values={ { hashLink } } />
|
||||||
|
}</p>
|
||||||
{ this.renderConfirmations() }
|
{ this.renderConfirmations() }
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -98,20 +99,22 @@ class TxHash extends Component {
|
|||||||
color='white'
|
color='white'
|
||||||
mode='indeterminate'
|
mode='indeterminate'
|
||||||
/>
|
/>
|
||||||
<div className={ styles.progressinfo }>waiting for confirmations</div>
|
<div className={ styles.progressinfo }>
|
||||||
|
<FormattedMessage
|
||||||
|
id='ui.txHash.waiting'
|
||||||
|
defaultMessage='waiting for confirmations' />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmations = blockNumber.minus(transaction.blockNumber).plus(1);
|
const confirmations = blockNumber.minus(transaction.blockNumber).plus(1);
|
||||||
const value = Math.min(confirmations.toNumber(), maxConfirmations);
|
const value = Math.min(confirmations.toNumber(), maxConfirmations);
|
||||||
let count;
|
|
||||||
if (confirmations.gt(maxConfirmations)) {
|
let count = confirmations.toFormat(0);
|
||||||
count = confirmations.toFormat(0);
|
if (confirmations.lte(maxConfirmations)) {
|
||||||
} else {
|
count = `${count}/${maxConfirmations}`;
|
||||||
count = confirmations.toFormat(0) + `/${maxConfirmations}`;
|
|
||||||
}
|
}
|
||||||
const unit = value === 1 ? 'confirmation' : 'confirmations';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={ styles.confirm }>
|
<div className={ styles.confirm }>
|
||||||
@ -121,10 +124,17 @@ class TxHash extends Component {
|
|||||||
max={ maxConfirmations }
|
max={ maxConfirmations }
|
||||||
value={ value }
|
value={ value }
|
||||||
color='white'
|
color='white'
|
||||||
mode='determinate'
|
mode='determinate' />
|
||||||
/>
|
|
||||||
<div className={ styles.progressinfo }>
|
<div className={ styles.progressinfo }>
|
||||||
<abbr title={ `block #${blockNumber.toFormat(0)}` }>{ count } { unit }</abbr>
|
<abbr title={ `block #${blockNumber.toFormat(0)}` }>
|
||||||
|
<FormattedMessage
|
||||||
|
id='ui.txHash.confirmations'
|
||||||
|
defaultMessage='{count} {value, plural, one {confirmation} other {confirmations}}'
|
||||||
|
values={ {
|
||||||
|
count,
|
||||||
|
value
|
||||||
|
} } />
|
||||||
|
</abbr>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -138,15 +148,17 @@ class TxHash extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ blockNumber });
|
return api.eth
|
||||||
|
|
||||||
api.eth
|
|
||||||
.getTransactionReceipt(hash)
|
.getTransactionReceipt(hash)
|
||||||
.then((transaction) => {
|
.then((transaction) => {
|
||||||
this.setState({ transaction });
|
this.setState({
|
||||||
|
blockNumber,
|
||||||
|
transaction
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.warn('onBlockNumber', error);
|
console.warn('onBlockNumber', error);
|
||||||
|
this.setState({ blockNumber });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
132
js/src/ui/TxHash/txHash.spec.js
Normal file
132
js/src/ui/TxHash/txHash.spec.js
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
// 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 BigNumber from 'bignumber.js';
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import React from 'react';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
|
||||||
|
import TxHash from './';
|
||||||
|
|
||||||
|
const TXHASH = '0xabcdef123454321abcdef';
|
||||||
|
|
||||||
|
let api;
|
||||||
|
let blockNumber;
|
||||||
|
let callback;
|
||||||
|
let component;
|
||||||
|
let instance;
|
||||||
|
|
||||||
|
function createApi () {
|
||||||
|
blockNumber = new BigNumber(100);
|
||||||
|
api = {
|
||||||
|
eth: {
|
||||||
|
getTransactionReceipt: (hash) => {
|
||||||
|
return Promise.resolve({
|
||||||
|
blockNumber: new BigNumber(100),
|
||||||
|
hash
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nextBlock: (increment = 1) => {
|
||||||
|
blockNumber = blockNumber.plus(increment);
|
||||||
|
return callback(null, blockNumber);
|
||||||
|
},
|
||||||
|
subscribe: (type, _callback) => {
|
||||||
|
callback = _callback;
|
||||||
|
return callback(null, blockNumber).then(() => {
|
||||||
|
return Promise.resolve(1);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
unsubscribe: sinon.stub().resolves(true)
|
||||||
|
};
|
||||||
|
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRedux () {
|
||||||
|
return {
|
||||||
|
dispatch: sinon.stub(),
|
||||||
|
subscribe: sinon.stub(),
|
||||||
|
getState: () => {
|
||||||
|
return {
|
||||||
|
nodeStatus: { isTest: true }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function render (props) {
|
||||||
|
const baseComponent = shallow(
|
||||||
|
<TxHash
|
||||||
|
hash={ TXHASH }
|
||||||
|
{ ...props } />,
|
||||||
|
{ context: { store: createRedux() } }
|
||||||
|
);
|
||||||
|
component = baseComponent.find('TxHash').shallow({ context: { api: createApi() } });
|
||||||
|
instance = component.instance();
|
||||||
|
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ui/TxHash', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders defaults', () => {
|
||||||
|
expect(component).to.be.ok;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the summary', () => {
|
||||||
|
expect(component.find('p').find('FormattedMessage').props().id).to.equal('ui.txHash.posted');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderConfirmations', () => {
|
||||||
|
describe('with no transaction retrieved', () => {
|
||||||
|
let child;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
child = shallow(instance.renderConfirmations());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders indeterminate progressbar', () => {
|
||||||
|
expect(child.find('LinearProgress[mode="indeterminate"]')).to.have.length(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders waiting text', () => {
|
||||||
|
expect(child.find('FormattedMessage').props().id).to.equal('ui.txHash.waiting');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with transaction retrieved', () => {
|
||||||
|
let child;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
return instance.componentDidMount().then(() => {
|
||||||
|
child = shallow(instance.renderConfirmations());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders determinate progressbar', () => {
|
||||||
|
expect(child.find('LinearProgress[mode="determinate"]')).to.have.length(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders confirmation text', () => {
|
||||||
|
expect(child.find('FormattedMessage').props().id).to.equal('ui.txHash.confirmations');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -25,7 +25,7 @@ import TxRow from './txRow';
|
|||||||
|
|
||||||
const api = new Api({ execute: sinon.stub() });
|
const api = new Api({ execute: sinon.stub() });
|
||||||
|
|
||||||
function renderShallow (props) {
|
function render (props) {
|
||||||
return shallow(
|
return shallow(
|
||||||
<TxRow
|
<TxRow
|
||||||
{ ...props } />,
|
{ ...props } />,
|
||||||
@ -33,7 +33,7 @@ function renderShallow (props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ui/TxRow', () => {
|
describe('ui/TxList/TxRow', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders defaults', () => {
|
it('renders defaults', () => {
|
||||||
const block = {
|
const block = {
|
||||||
@ -45,7 +45,7 @@ describe('ui/TxRow', () => {
|
|||||||
value: new BigNumber(1)
|
value: new BigNumber(1)
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(renderShallow({ block, tx })).to.be.ok;
|
expect(render({ address: '0x123', block, isTest: true, tx })).to.be.ok;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -36,7 +36,7 @@ const STORE = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderShallow (props) {
|
function render (props) {
|
||||||
return shallow(
|
return shallow(
|
||||||
<TxList
|
<TxList
|
||||||
store={ STORE }
|
store={ STORE }
|
||||||
@ -48,7 +48,7 @@ function renderShallow (props) {
|
|||||||
describe('ui/TxList', () => {
|
describe('ui/TxList', () => {
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('renders defaults', () => {
|
it('renders defaults', () => {
|
||||||
expect(renderShallow()).to.be.ok;
|
expect(render({ address: '0x123', hashes: [] })).to.be.ok;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user