Merge pull request #72 from andrerfneves/feature/transactions-infinite-loader

Feature/transactions infinite loader
This commit is contained in:
André Neves 2019-02-16 18:43:21 -05:00 committed by GitHub
commit 84df504703
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 298 additions and 2348 deletions

View File

@ -30,6 +30,7 @@ describe('Transactions Actions', () => {
const payload = { const payload = {
list: [], list: [],
zecPrice: 0, zecPrice: 0,
hasNextPage: false,
}; };
store.dispatch(loadTransactionsSuccess(payload)); store.dispatch(loadTransactionsSuccess(payload));

View File

@ -14,7 +14,7 @@ describe('<LoadingScreen />', () => {
test('should render status pill correctly', () => { test('should render status pill correctly', () => {
const { queryByTestId } = render( const { queryByTestId } = render(
<ThemeProvider theme={appTheme}> <ThemeProvider theme={appTheme}>
<LoadingScreen progress={83.0} /> <LoadingScreen progress={83.0} message='ZEC Wallet Starting' />
</ThemeProvider>, </ThemeProvider>,
); );

View File

@ -3,8 +3,6 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { Transition, animated } from 'react-spring'; import { Transition, animated } from 'react-spring';
// eslint-disable-next-line import/no-extraneous-dependencies
import { ipcRenderer } from 'electron';
import CircleProgressComponent from 'react-circle'; import CircleProgressComponent from 'react-circle';
import { TextComponent } from './text'; import { TextComponent } from './text';
@ -41,35 +39,27 @@ const Logo = styled.img`
type Props = { type Props = {
progress: number, progress: number,
message: string,
}; };
type State = { type State = {
start: boolean, start: boolean,
message: string,
}; };
const TIME_DELAY_ANIM = 100; const TIME_DELAY_ANIM = 100;
export class LoadingScreen extends PureComponent<Props, State> { export class LoadingScreen extends PureComponent<Props, State> {
state = { start: false, message: 'ZEC Wallet Starting' }; state = { start: false };
componentDidMount() { componentDidMount() {
setTimeout(() => { setTimeout(() => {
this.setState(() => ({ start: true })); this.setState(() => ({ start: true }));
}, TIME_DELAY_ANIM); }, TIME_DELAY_ANIM);
ipcRenderer.on('zcashd-params-download', (event: Object, message: string) => {
this.setState(() => ({ message }));
});
}
componentWillUnmount() {
ipcRenderer.removeAllListeners('zcashd-log');
} }
render() { render() {
const { start, message } = this.state; const { start } = this.state;
const { progress } = this.props; const { progress, message } = this.props;
return ( return (
<Wrapper data-testid='LoadingScreen'> <Wrapper data-testid='LoadingScreen'>

View File

@ -188,7 +188,13 @@ export const TransactionDetailsComponent = ({
<InfoRow> <InfoRow>
<ColumnComponent width='100%'> <ColumnComponent width='100%'>
<Label value='TRANSACTION ID' /> <Label value='TRANSACTION ID' />
<TransactionId onClick={() => openExternal(ZCASH_EXPLORER_BASE_URL + transactionId)}> <TransactionId
onClick={
from !== '(Shielded)'
? () => openExternal(ZCASH_EXPLORER_BASE_URL + transactionId)
: () => {}
}
>
<Ellipsis value={transactionId} /> <Ellipsis value={transactionId} />
</TransactionId> </TransactionId>
</ColumnComponent> </ColumnComponent>

View File

@ -11,6 +11,7 @@ type Props = {};
type State = { type State = {
isRunning: boolean, isRunning: boolean,
progress: number, progress: number,
message: string,
}; };
/* eslint-disable max-len */ /* eslint-disable max-len */
@ -22,6 +23,7 @@ export const withDaemonStatusCheck = <PassedProps: {}>(
state = { state = {
isRunning: false, isRunning: false,
progress: 0, progress: 0,
message: 'ZEC Wallet Starting',
}; };
componentDidMount() { componentDidMount() {
@ -53,21 +55,23 @@ export const withDaemonStatusCheck = <PassedProps: {}>(
} }
} }
}) })
.catch(() => { .catch((error) => {
const statusMessage = error.message === 'Something went wrong' ? 'ZEC Wallet Starting' : error.message;
this.setState((state) => { this.setState((state) => {
const newProgress = state.progress > 70 ? state.progress + 2.5 : state.progress + 5; const newProgress = state.progress > 70 ? state.progress + 2.5 : state.progress + 5;
return { progress: newProgress > 95 ? 95 : newProgress }; return { progress: newProgress > 95 ? 95 : newProgress, message: statusMessage };
}); });
}); });
}; };
render() { render() {
const { isRunning, progress } = this.state; const { isRunning, progress, message } = this.state;
if (isRunning) { if (isRunning) {
return <WrappedComponent {...this.props} {...this.state} />; return <WrappedComponent {...this.props} {...this.state} />;
} }
return <LoadingScreen progress={progress} />; return <LoadingScreen progress={progress} message={message} />;
} }
}; };

View File

@ -2,16 +2,15 @@
import eres from 'eres'; import eres from 'eres';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import flow from 'lodash.flow';
import groupBy from 'lodash.groupby';
import dateFns from 'date-fns';
import { BigNumber } from 'bignumber.js'; import { BigNumber } from 'bignumber.js';
import uuidv4 from 'uuid/v4';
import { TransactionsView } from '../views/transactions'; import { TransactionsView } from '../views/transactions';
import { import {
loadTransactions, loadTransactions,
loadTransactionsSuccess, loadTransactionsSuccess,
loadTransactionsError, loadTransactionsError,
resetTransactionsList,
} from '../redux/modules/transactions'; } from '../redux/modules/transactions';
import rpc from '../../services/api'; import rpc from '../../services/api';
import { listShieldedTransactions } from '../../services/shielded-transactions'; import { listShieldedTransactions } from '../../services/shielded-transactions';
@ -21,45 +20,64 @@ import { sortByDescend } from '../utils/sort-by-descend';
import type { AppState } from '../types/app-state'; import type { AppState } from '../types/app-state';
import type { Dispatch } from '../types/redux'; import type { Dispatch } from '../types/redux';
import type { Transaction } from '../components/transaction-item';
const mapStateToProps = ({ transactions }: AppState) => ({ const mapStateToProps = ({ transactions }: AppState) => ({
transactions: transactions.list, transactions: transactions.list,
isLoading: transactions.isLoading, isLoading: transactions.isLoading,
error: transactions.error, error: transactions.error,
zecPrice: transactions.zecPrice, zecPrice: transactions.zecPrice,
hasNextPage: transactions.hasNextPage,
}); });
const mapDispatchToProps = (dispatch: Dispatch) => ({ export type MapStateToProps = {
getTransactions: async () => { transactions: Transaction[],
isLoading: boolean,
error: string | null,
zecPrice: number,
hasNextPage: boolean,
};
export type MapDispatchToProps = {|
getTransactions: ({
offset: number,
count: number,
shieldedTransactionsCount: number,
}) => Promise<void>,
resetTransactionsList: () => void,
|};
const mapDispatchToProps = (dispatch: Dispatch): MapDispatchToProps => ({
resetTransactionsList: () => dispatch(resetTransactionsList()),
getTransactions: async ({ offset, count, shieldedTransactionsCount }) => {
dispatch(loadTransactions()); dispatch(loadTransactions());
const [transactionsErr, transactions = []] = await eres(rpc.listtransactions('', 200)); const [transactionsErr, transactions = []] = await eres(
rpc.listtransactions('', count, offset),
);
if (transactionsErr) { if (transactionsErr) {
return dispatch(loadTransactionsError({ error: transactionsErr.message })); return dispatch(loadTransactionsError({ error: transactionsErr.message }));
} }
const formattedTransactions = flow([ const formattedTransactions = sortByDescend('date')(
arr => arr.map(transaction => ({ [
transactionId: transaction.txid, ...transactions,
...listShieldedTransactions({ count, offset: shieldedTransactionsCount }),
].map(transaction => ({
transactionId: transaction.txid ? transaction.txid : uuidv4(),
type: transaction.category, type: transaction.category,
date: new Date(transaction.time * 1000).toISOString(), date: new Date(transaction.time * 1000).toISOString(),
address: transaction.address, address: transaction.address,
amount: new BigNumber(transaction.amount).absoluteValue().toNumber(), amount: new BigNumber(transaction.amount).absoluteValue().toNumber(),
})), })),
arr => groupBy(arr, obj => dateFns.format(obj.date, 'MMM DD, YYYY')), );
obj => Object.keys(obj).map(day => ({
day,
jsDay: new Date(day),
list: sortByDescend('date')(obj[day]),
})),
sortByDescend('jsDay'),
])([...transactions, ...listShieldedTransactions()]);
dispatch( dispatch(
loadTransactionsSuccess({ loadTransactionsSuccess({
list: formattedTransactions, list: formattedTransactions,
zecPrice: new BigNumber(store.get('ZEC_DOLLAR_PRICE')).toNumber(), zecPrice: new BigNumber(store.get('ZEC_DOLLAR_PRICE')).toNumber(),
hasNextPage: Boolean(formattedTransactions.length),
}), }),
); );
}, },

View File

@ -1,5 +1,5 @@
// @flow // @flow
import uniqBy from 'lodash.uniqby';
import type { Action } from '../../types/redux'; import type { Action } from '../../types/redux';
import type { Transaction } from '../../components/transaction-item'; import type { Transaction } from '../../components/transaction-item';
@ -7,6 +7,7 @@ import type { Transaction } from '../../components/transaction-item';
export const LOAD_TRANSACTIONS = 'LOAD_TRANSACTIONS'; export const LOAD_TRANSACTIONS = 'LOAD_TRANSACTIONS';
export const LOAD_TRANSACTIONS_SUCCESS = 'LOAD_TRANSACTIONS_SUCCESS'; export const LOAD_TRANSACTIONS_SUCCESS = 'LOAD_TRANSACTIONS_SUCCESS';
export const LOAD_TRANSACTIONS_ERROR = 'LOAD_TRANSACTIONS_ERROR'; export const LOAD_TRANSACTIONS_ERROR = 'LOAD_TRANSACTIONS_ERROR';
export const RESET_TRANSACTIONS_LIST = 'RESET_TRANSACTIONS_LIST';
export type TransactionsList = { day: string, list: Transaction[] }[]; export type TransactionsList = { day: string, list: Transaction[] }[];
@ -18,14 +19,17 @@ export const loadTransactions = () => ({
export const loadTransactionsSuccess = ({ export const loadTransactionsSuccess = ({
list, list,
zecPrice, zecPrice,
hasNextPage,
}: { }: {
list: TransactionsList, list: Transaction[],
zecPrice: number, zecPrice: number,
hasNextPage: boolean,
}) => ({ }) => ({
type: LOAD_TRANSACTIONS_SUCCESS, type: LOAD_TRANSACTIONS_SUCCESS,
payload: { payload: {
list, list,
zecPrice, zecPrice,
hasNextPage,
}, },
}); });
@ -34,11 +38,17 @@ export const loadTransactionsError = ({ error }: { error: string }) => ({
payload: { error }, payload: { error },
}); });
export const resetTransactionsList = () => ({
type: RESET_TRANSACTIONS_LIST,
payload: {},
});
export type State = { export type State = {
isLoading: boolean, isLoading: boolean,
error: string | null, error: string | null,
list: TransactionsList, list: Transaction[],
zecPrice: number, zecPrice: number,
hasNextPage: boolean,
}; };
const initialState = { const initialState = {
@ -46,6 +56,7 @@ const initialState = {
list: [], list: [],
error: null, error: null,
isLoading: false, isLoading: false,
hasNextPage: true,
}; };
// eslint-disable-next-line // eslint-disable-next-line
@ -61,6 +72,7 @@ export default (state: State = initialState, action: Action) => {
return { return {
...state, ...state,
...action.payload, ...action.payload,
list: uniqBy(state.list.concat(action.payload.list), tr => tr.transactionId + tr.type),
isLoading: false, isLoading: false,
error: null, error: null,
}; };
@ -70,6 +82,13 @@ export default (state: State = initialState, action: Action) => {
isLoading: false, isLoading: false,
error: action.payload.error, error: action.payload.error,
}; };
case RESET_TRANSACTIONS_LIST:
return {
...state,
isLoading: false,
error: null,
list: [],
};
default: default:
return state; return state;
} }

View File

@ -1,48 +1,198 @@
// @flow // @flow
import React, { PureComponent, Fragment } from 'react'; import React, { PureComponent, Fragment, type Element } from 'react';
import { InfiniteLoader, AutoSizer, List } from 'react-virtualized';
import styled from 'styled-components';
import dateFns from 'date-fns';
import { TransactionDailyComponent } from '../components/transaction-daily'; import { TransactionItemComponent } from '../components/transaction-item';
import { TextComponent } from '../components/text'; import { TextComponent } from '../components/text';
import { EmptyTransactionsComponent } from '../components/empty-transactions'; import { EmptyTransactionsComponent } from '../components/empty-transactions';
import type { TransactionsList } from '../redux/modules/transactions'; import type { MapDispatchToProps, MapStateToProps } from '../containers/transactions';
type Props = { type Props = MapDispatchToProps & MapStateToProps;
error: string | null,
transactions: TransactionsList, const PAGE_SIZE = 15;
zecPrice: number, const ROW_HEIGHT = 60;
getTransactions: () => void, const ROW_HEIGHT_WITH_HEADER = 88;
};
const Day = styled(TextComponent)`
text-transform: uppercase;
color: ${props => props.theme.colors.transactionsDate};
font-size: ${props => `${props.theme.fontSize.regular * 0.9}em`};
font-weight: ${props => String(props.theme.fontWeight.bold)};
margin-top: 10px;
margin-bottom: 5px;
`;
const RoundedTransactionWrapper = styled.div`
overflow: hidden;
${props => (props.roundPosition === 'top'
? `
border-top-left-radius: ${props.theme.boxBorderRadius};
border-top-right-radius: ${props.theme.boxBorderRadius};`
: `border-bottom-left-radius: ${props.theme.boxBorderRadius};
border-bottom-right-radius: ${props.theme.boxBorderRadius};`)}
`;
export class TransactionsView extends PureComponent<Props> { export class TransactionsView extends PureComponent<Props> {
componentDidMount() { componentDidMount() {
// eslint-disable-next-line const { getTransactions, resetTransactionsList } = this.props;
this.props.getTransactions(); resetTransactionsList();
getTransactions({ count: PAGE_SIZE, offset: 0, shieldedTransactionsCount: 0 });
} }
isRowLoaded = ({ index }: { index: number }) => {
const { hasNextPage, transactions } = this.props;
const transactionsSize = transactions.length;
return !hasNextPage || index < transactionsSize;
};
renderTransactionWrapper = ({
index,
transactionDate,
previousTransactionDate,
nextTransactionDate,
component,
}: {|
index: number,
transactionDate: Date,
previousTransactionDate: ?Date,
nextTransactionDate: ?Date,
component: Element<*>,
|}) => {
if (
index === 0
|| (previousTransactionDate && !dateFns.isSameDay(transactionDate, previousTransactionDate))
) {
return <RoundedTransactionWrapper roundPosition='top'>{component}</RoundedTransactionWrapper>;
}
if (
nextTransactionDate
&& (nextTransactionDate && !dateFns.isSameDay(transactionDate, nextTransactionDate))
) {
return (
<RoundedTransactionWrapper roundPosition='bottom'>{component}</RoundedTransactionWrapper>
);
}
return component;
};
renderTransactions = ({ index }: { index: number }) => {
const { transactions, zecPrice } = this.props;
const transaction = transactions[index];
const previousTransaction = transactions[index - 1];
const nextTransaction = transactions[index + 1];
const transactionItem = this.renderTransactionWrapper({
transactionDate: new Date(transaction.date),
previousTransactionDate: previousTransaction ? new Date(previousTransaction.date) : null,
nextTransactionDate: nextTransaction ? new Date(nextTransaction.date) : null,
component: (
<TransactionItemComponent
address={transaction.address}
amount={transaction.amount}
date={transaction.date}
transactionId={transaction.transactionId}
type={transaction.type}
zecPrice={zecPrice}
/>
),
index,
});
if (
index === 0
|| (previousTransaction
&& !dateFns.isSameDay(new Date(previousTransaction.date), new Date(transaction.date)))
) {
return (
<Fragment>
<Day value={dateFns.format(new Date(transaction.date), 'MMM DD, YYYY')} />
{transactionItem}
</Fragment>
);
}
return transactionItem;
};
renderRow = ({ index, key, style }: { index: number, key: string, style: Object }) => (
<div key={key} style={style}>
{this.isRowLoaded({ index }) ? this.renderTransactions({ index }) : 'Loading...'}
</div>
);
getRowHeight = ({ index }: { index: number }) => {
const { transactions } = this.props;
const transaction = transactions[index];
if (
index === 0
|| !dateFns.isSameDay(new Date(transactions[index - 1].date), new Date(transaction.date))
) {
return ROW_HEIGHT_WITH_HEADER;
}
return ROW_HEIGHT;
};
loadNextPage = () => {
const { transactions, getTransactions } = this.props;
const shieldedTransactionsCount = transactions.filter(
transaction => transaction.address === '(Shielded)',
).length;
getTransactions({ count: PAGE_SIZE, offset: transactions.length, shieldedTransactionsCount });
};
loadMoreRows = async () => {
const { isLoading } = this.props;
return isLoading ? Promise.resolve([]) : this.loadNextPage();
};
render() { render() {
const { error, transactions, zecPrice } = this.props; const { error, transactions, hasNextPage } = this.props;
const transactionsSize = transactions.length;
const isRowLoaded = ({ index }) => !hasNextPage || index < transactionsSize;
const rowCount = transactionsSize ? transactionsSize + 1 : transactionsSize;
if (error) { if (error) {
return <TextComponent value={error} />; return <TextComponent value={error} />;
} }
return ( return (
<Fragment> <InfiniteLoader
{transactions.length === 0 ? ( isRowLoaded={isRowLoaded}
<EmptyTransactionsComponent /> loadMoreRows={this.loadMoreRows}
) : ( rowCount={rowCount}
transactions.map(({ day, list }) => ( >
<TransactionDailyComponent {({ onRowsRendered, registerChild }) => (
transactionsDate={day} <AutoSizer>
transactions={list} {({ width, height }) => (
zecPrice={zecPrice} <List
key={day} noRowsRenderer={EmptyTransactionsComponent}
/> ref={registerChild}
)) onRowsRendered={onRowsRendered}
rowRenderer={this.renderRow}
rowHeight={this.getRowHeight}
rowCount={transactionsSize}
width={width}
height={height - 20}
/>
)}
</AutoSizer>
)} )}
</Fragment> </InfiniteLoader>
); );
} }
} }

View File

@ -0,0 +1,3 @@
declare module 'lodash.uniqby' {
declare module.exports: <T>(arr: T[], (T) => $Values<T>) => T[];
}

View File

@ -108,6 +108,7 @@
"history": "^4.7.2", "history": "^4.7.2",
"lodash.flow": "^3.5.0", "lodash.flow": "^3.5.0",
"lodash.groupby": "^4.6.0", "lodash.groupby": "^4.6.0",
"lodash.uniqby": "^4.7.0",
"p-queue": "^3.0.0", "p-queue": "^3.0.0",
"process-exists": "^3.1.0", "process-exists": "^3.1.0",
"qrcode.react": "^0.8.0", "qrcode.react": "^0.8.0",
@ -119,6 +120,7 @@
"react-redux": "^5.0.7", "react-redux": "^5.0.7",
"react-router-dom": "^4.2.2", "react-router-dom": "^4.2.2",
"react-spring": "^7.2.10", "react-spring": "^7.2.10",
"react-virtualized": "^9.21.0",
"redux": "^4.0.1", "redux": "^4.0.1",
"redux-thunk": "^2.2.0", "redux-thunk": "^2.2.0",
"styled-components": "^4.1.1", "styled-components": "^4.1.1",

View File

@ -12,7 +12,20 @@ type ShieldedTransaction = {|
|}; |};
// eslint-disable-next-line // eslint-disable-next-line
export const listShieldedTransactions = (): Array<ShieldedTransaction> => electronStore.has(STORE_KEY) ? electronStore.get(STORE_KEY) : []; export const listShieldedTransactions = (
pagination: ?{
offset: number,
count: number,
},
): Array<ShieldedTransaction> => {
const transactions = electronStore.has(STORE_KEY) ? electronStore.get(STORE_KEY) : [];
if (!pagination) return transactions;
const { offset = 0, count = 10 } = pagination;
return transactions.slice(offset - 1, offset + count);
};
export const saveShieldedTransaction = ({ export const saveShieldedTransaction = ({
category, category,

2308
yarn.lock

File diff suppressed because it is too large Load Diff