From cfeb952ac0556f750eed2b2dfb1b0ab7e0787f94 Mon Sep 17 00:00:00 2001 From: manuelwedler Date: Wed, 11 Nov 2020 16:22:12 +0100 Subject: [PATCH 01/17] Make 360px the default dialog width to remove config duplication --- src/app/app.component.spec.ts | 1 - src/app/app.component.ts | 1 - src/app/app.module.ts | 2 +- .../components/channel-list/channel-list.component.spec.ts | 2 -- src/app/components/channel-list/channel-list.component.ts | 1 - src/app/components/channel/channel.component.spec.ts | 4 ---- src/app/components/channel/channel.component.ts | 2 -- .../components/contact-list/contact-list.component.spec.ts | 1 - src/app/components/contact-list/contact-list.component.ts | 1 - .../contact-actions/contact-actions.component.spec.ts | 4 ---- .../contact/contact-actions/contact-actions.component.ts | 3 --- src/app/components/header/header.component.spec.ts | 1 - src/app/components/header/header.component.ts | 1 - .../payment-dialog/payment-dialog.component.spec.ts | 2 -- .../components/payment-dialog/payment-dialog.component.ts | 1 - .../token-network-selector.component.spec.ts | 4 +--- .../token-network-selector.component.ts | 4 +--- src/app/components/token/token.component.spec.ts | 7 ------- src/app/components/token/token.component.ts | 4 ---- 19 files changed, 3 insertions(+), 43 deletions(-) diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index d419da32..7bde345f 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -305,7 +305,6 @@ describe('AppComponent', () => { expect(dialogSpy).toHaveBeenCalledTimes(1); expect(dialogSpy).toHaveBeenCalledWith(ConfirmationDialogComponent, { data: payload, - width: '360px', }); expect(shutdownSpy).toHaveBeenCalledTimes(1); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 33a3618d..c48f99b4 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -170,7 +170,6 @@ export class AppComponent implements OnInit, OnDestroy { }; const dialog = this.dialog.open(ConfirmationDialogComponent, { data: payload, - width: '360px', }); dialog diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 91d7468f..7611a979 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -174,7 +174,7 @@ export function ConfigLoader(raidenConfig: RaidenConfig) { provide: MAT_DIALOG_DEFAULT_OPTIONS, useValue: Object.assign(new MatDialogConfig(), { maxWidth: '90vw', - width: '500px', + width: '360px', autoFocus: false, }), }, diff --git a/src/app/components/channel-list/channel-list.component.spec.ts b/src/app/components/channel-list/channel-list.component.spec.ts index 44f0b316..46da9492 100644 --- a/src/app/components/channel-list/channel-list.component.spec.ts +++ b/src/app/components/channel-list/channel-list.component.spec.ts @@ -233,7 +233,6 @@ describe('ChannelListComponent', () => { expect(dialogSpy).toHaveBeenCalledTimes(1); expect(dialogSpy).toHaveBeenCalledWith(OpenDialogComponent, { data: payload, - width: '360px', }); expect(openSpy).toHaveBeenCalledTimes(1); expect(openSpy).toHaveBeenCalledWith( @@ -280,7 +279,6 @@ describe('ChannelListComponent', () => { expect(dialogSpy).toHaveBeenCalledTimes(1); expect(dialogSpy).toHaveBeenCalledWith(OpenDialogComponent, { data: payload, - width: '360px', }); }); }); diff --git a/src/app/components/channel-list/channel-list.component.ts b/src/app/components/channel-list/channel-list.component.ts index f9ea28d2..8d97a054 100644 --- a/src/app/components/channel-list/channel-list.component.ts +++ b/src/app/components/channel-list/channel-list.component.ts @@ -127,7 +127,6 @@ export class ChannelListComponent implements OnInit, OnDestroy, AfterViewInit { const dialog = this.dialog.open(OpenDialogComponent, { data: payload, - width: '360px', }); dialog diff --git a/src/app/components/channel/channel.component.spec.ts b/src/app/components/channel/channel.component.spec.ts index b1625f45..3a313c84 100644 --- a/src/app/components/channel/channel.component.spec.ts +++ b/src/app/components/channel/channel.component.spec.ts @@ -118,7 +118,6 @@ describe('ChannelComponent', () => { expect(dialogSpy).toHaveBeenCalledTimes(1); expect(dialogSpy).toHaveBeenCalledWith(DepositWithdrawDialogComponent, { data: payload, - width: '360px', }); expect(depositSpy).toHaveBeenCalledTimes(1); expect(depositSpy).toHaveBeenCalledWith( @@ -151,7 +150,6 @@ describe('ChannelComponent', () => { expect(dialogSpy).toHaveBeenCalledTimes(1); expect(dialogSpy).toHaveBeenCalledWith(DepositWithdrawDialogComponent, { data: payload, - width: '360px', }); expect(depositSpy).toHaveBeenCalledTimes(1); expect(depositSpy).toHaveBeenCalledWith( @@ -195,7 +193,6 @@ describe('ChannelComponent', () => { expect(dialogSpy).toHaveBeenCalledTimes(1); expect(dialogSpy).toHaveBeenCalledWith(ConfirmationDialogComponent, { data: payload, - width: '360px', }); expect(closeSpy).toHaveBeenCalledTimes(1); expect(closeSpy).toHaveBeenCalledWith( @@ -235,7 +232,6 @@ describe('ChannelComponent', () => { expect(dialogSpy).toHaveBeenCalledTimes(1); expect(dialogSpy).toHaveBeenCalledWith(ConfirmationDialogComponent, { data: payload, - width: '360px', }); }); }); diff --git a/src/app/components/channel/channel.component.ts b/src/app/components/channel/channel.component.ts index 5fe4e8f9..5548d478 100644 --- a/src/app/components/channel/channel.component.ts +++ b/src/app/components/channel/channel.component.ts @@ -60,7 +60,6 @@ export class ChannelComponent implements OnInit { const dialog = this.dialog.open(ConfirmationDialogComponent, { data: payload, - width: '360px', }); dialog @@ -91,7 +90,6 @@ export class ChannelComponent implements OnInit { const dialog = this.dialog.open(DepositWithdrawDialogComponent, { data: payload, - width: '360px', }); dialog diff --git a/src/app/components/contact-list/contact-list.component.spec.ts b/src/app/components/contact-list/contact-list.component.spec.ts index ddda9292..e64a911c 100644 --- a/src/app/components/contact-list/contact-list.component.spec.ts +++ b/src/app/components/contact-list/contact-list.component.spec.ts @@ -177,7 +177,6 @@ describe('ContactListComponent', () => { AddEditContactDialogComponent, { data: payload, - width: '360px', } ); expect(saveSpy).toHaveBeenCalledTimes(1); diff --git a/src/app/components/contact-list/contact-list.component.ts b/src/app/components/contact-list/contact-list.component.ts index 7395af17..b942f7e0 100644 --- a/src/app/components/contact-list/contact-list.component.ts +++ b/src/app/components/contact-list/contact-list.component.ts @@ -109,7 +109,6 @@ export class ContactListComponent implements OnInit, OnDestroy, AfterViewInit { addContact() { const dialog = this.dialog.open(AddEditContactDialogComponent, { data: { address: '', label: '', edit: false }, - width: '360px', }); dialog.afterClosed().subscribe((result?: Contact) => { diff --git a/src/app/components/contact/contact-actions/contact-actions.component.spec.ts b/src/app/components/contact/contact-actions/contact-actions.component.spec.ts index a06702ad..99e8b3d3 100644 --- a/src/app/components/contact/contact-actions/contact-actions.component.spec.ts +++ b/src/app/components/contact/contact-actions/contact-actions.component.spec.ts @@ -133,7 +133,6 @@ describe('ContactActionsComponent', () => { expect(dialogSpy).toHaveBeenCalledTimes(1); expect(dialogSpy).toHaveBeenCalledWith(PaymentDialogComponent, { data: payload, - width: '360px', }); expect(initiatePaymentSpy).toHaveBeenCalledTimes(1); expect(initiatePaymentSpy).toHaveBeenCalledWith( @@ -180,7 +179,6 @@ describe('ContactActionsComponent', () => { expect(dialogSpy).toHaveBeenCalledTimes(1); expect(dialogSpy).toHaveBeenCalledWith(PaymentDialogComponent, { data: payload, - width: '360px', }); }); @@ -204,7 +202,6 @@ describe('ContactActionsComponent', () => { expect(dialogSpy).toHaveBeenCalledTimes(1); expect(dialogSpy).toHaveBeenCalledWith(AddEditContactDialogComponent, { data: payload, - width: '360px', }); expect(saveSpy).toHaveBeenCalledTimes(1); expect(saveSpy).toHaveBeenCalledWith(dialogResult); @@ -224,7 +221,6 @@ describe('ContactActionsComponent', () => { expect(dialogSpy).toHaveBeenCalledTimes(1); expect(dialogSpy).toHaveBeenCalledWith(ConfirmationDialogComponent, { data: payload, - width: '360px', }); expect(deleteSpy).toHaveBeenCalledTimes(1); expect(deleteSpy).toHaveBeenCalledWith(contact); diff --git a/src/app/components/contact/contact-actions/contact-actions.component.ts b/src/app/components/contact/contact-actions/contact-actions.component.ts index ab3e913a..0b8ffbc1 100644 --- a/src/app/components/contact/contact-actions/contact-actions.component.ts +++ b/src/app/components/contact/contact-actions/contact-actions.component.ts @@ -100,7 +100,6 @@ export class ContactActionsComponent implements OnInit, OnDestroy { const dialog = this.dialog.open(PaymentDialogComponent, { data: payload, - width: '360px', }); dialog @@ -138,7 +137,6 @@ export class ContactActionsComponent implements OnInit, OnDestroy { const dialog = this.dialog.open(AddEditContactDialogComponent, { data: payload, - width: '360px', }); dialog.afterClosed().subscribe((result?: Contact) => { @@ -159,7 +157,6 @@ export class ContactActionsComponent implements OnInit, OnDestroy { const dialog = this.dialog.open(ConfirmationDialogComponent, { data: payload, - width: '360px', }); dialog.afterClosed().subscribe((result) => { diff --git a/src/app/components/header/header.component.spec.ts b/src/app/components/header/header.component.spec.ts index f4b8828b..9edc114e 100644 --- a/src/app/components/header/header.component.spec.ts +++ b/src/app/components/header/header.component.spec.ts @@ -159,7 +159,6 @@ describe('HeaderComponent', () => { expect(dialogSpy).toHaveBeenCalledTimes(1); expect(dialogSpy).toHaveBeenCalledWith(QrCodeComponent, { data: payload, - width: '360px', }); }); diff --git a/src/app/components/header/header.component.ts b/src/app/components/header/header.component.ts index fb4e1189..219d74e0 100644 --- a/src/app/components/header/header.component.ts +++ b/src/app/components/header/header.component.ts @@ -122,7 +122,6 @@ export class HeaderComponent implements OnInit, OnDestroy { }; this.dialog.open(QrCodeComponent, { data: payload, - width: '360px', }); } diff --git a/src/app/components/payment-dialog/payment-dialog.component.spec.ts b/src/app/components/payment-dialog/payment-dialog.component.spec.ts index 39354ae1..88c9aea2 100644 --- a/src/app/components/payment-dialog/payment-dialog.component.spec.ts +++ b/src/app/components/payment-dialog/payment-dialog.component.spec.ts @@ -242,7 +242,6 @@ describe('PaymentDialogComponent', () => { expect(dialogSpy).toHaveBeenCalledTimes(1); expect(dialogSpy).toHaveBeenCalledWith(ConfirmationDialogComponent, { data: payload, - width: '360px', }); expect(close).toHaveBeenCalledTimes(1); expect(close).toHaveBeenCalledWith({ @@ -277,7 +276,6 @@ describe('PaymentDialogComponent', () => { expect(dialogSpy).toHaveBeenCalledTimes(1); expect(dialogSpy).toHaveBeenCalledWith(ConfirmationDialogComponent, { data: payload, - width: '360px', }); }); diff --git a/src/app/components/payment-dialog/payment-dialog.component.ts b/src/app/components/payment-dialog/payment-dialog.component.ts index ce2cf7f4..b7697bfb 100644 --- a/src/app/components/payment-dialog/payment-dialog.component.ts +++ b/src/app/components/payment-dialog/payment-dialog.component.ts @@ -138,7 +138,6 @@ export class PaymentDialogComponent implements OnInit { }; const dialog = this.dialog.open(ConfirmationDialogComponent, { data: confirmationPayload, - width: '360px', }); return dialog.afterClosed().pipe( diff --git a/src/app/components/token-network-selector/token-network-selector.component.spec.ts b/src/app/components/token-network-selector/token-network-selector.component.spec.ts index 2b3b687c..fcf0fff0 100644 --- a/src/app/components/token-network-selector/token-network-selector.component.spec.ts +++ b/src/app/components/token-network-selector/token-network-selector.component.spec.ts @@ -159,9 +159,7 @@ describe('TokenNetworkSelectorComponent', () => { fixture.detectChanges(); expect(dialogSpy).toHaveBeenCalledTimes(1); - expect(dialogSpy).toHaveBeenCalledWith(RegisterDialogComponent, { - width: '360px', - }); + expect(dialogSpy).toHaveBeenCalledWith(RegisterDialogComponent, {}); expect(registerSpy).toHaveBeenCalledTimes(1); expect(registerSpy).toHaveBeenCalledWith(tokenAddress); }); diff --git a/src/app/components/token-network-selector/token-network-selector.component.ts b/src/app/components/token-network-selector/token-network-selector.component.ts index e81c6bbe..48e7da7a 100644 --- a/src/app/components/token-network-selector/token-network-selector.component.ts +++ b/src/app/components/token-network-selector/token-network-selector.component.ts @@ -94,9 +94,7 @@ export class TokenNetworkSelectorComponent implements ControlValueAccessor { } register() { - const dialog = this.dialog.open(RegisterDialogComponent, { - width: '360px', - }); + const dialog = this.dialog.open(RegisterDialogComponent, {}); dialog .afterClosed() diff --git a/src/app/components/token/token.component.spec.ts b/src/app/components/token/token.component.spec.ts index bf551f0d..5efbdbda 100644 --- a/src/app/components/token/token.component.spec.ts +++ b/src/app/components/token/token.component.spec.ts @@ -164,7 +164,6 @@ describe('TokenComponent', () => { expect(dialogSpy).toHaveBeenCalledTimes(1); expect(dialogSpy).toHaveBeenCalledWith(PaymentDialogComponent, { data: payload, - width: '360px', }); expect(initiatePaymentSpy).toHaveBeenCalledTimes(1); expect(initiatePaymentSpy).toHaveBeenCalledWith( @@ -211,14 +210,12 @@ describe('TokenComponent', () => { ConfirmationDialogComponent, { data: confirmationPayload, - width: '360px', }, ]); expect(dialogSpy.calls.mostRecent().args).toEqual([ ConnectionManagerDialogComponent, { data: connectionManagerPayload, - width: '360px', }, ]); }); @@ -281,7 +278,6 @@ describe('TokenComponent', () => { expect(dialogSpy).toHaveBeenCalledTimes(1); expect(dialogSpy).toHaveBeenCalledWith(PaymentDialogComponent, { data: payload, - width: '360px', }); expect(initiatePaymentSpy).toHaveBeenCalledTimes(1); expect(initiatePaymentSpy).toHaveBeenCalledWith( @@ -336,14 +332,12 @@ describe('TokenComponent', () => { ConfirmationDialogComponent, { data: confirmationPayload, - width: '360px', }, ]); expect(dialogSpy.calls.mostRecent().args).toEqual([ ConnectionManagerDialogComponent, { data: connectionManagerPayload, - width: '360px', }, ]); expect(connectSpy).toHaveBeenCalledTimes(1); @@ -440,7 +434,6 @@ describe('TokenComponent', () => { ConfirmationDialogComponent, { data: payload, - width: '360px', } ); expect(leaveSpy).toHaveBeenCalledTimes(1); diff --git a/src/app/components/token/token.component.ts b/src/app/components/token/token.component.ts index f413b771..3567e70d 100644 --- a/src/app/components/token/token.component.ts +++ b/src/app/components/token/token.component.ts @@ -117,7 +117,6 @@ export class TokenComponent implements OnInit, OnDestroy { const dialog = this.dialog.open(PaymentDialogComponent, { data: payload, - width: '360px', }); dialog @@ -172,7 +171,6 @@ export class TokenComponent implements OnInit, OnDestroy { }; const dialog = this.dialog.open(ConfirmationDialogComponent, { data: payload, - width: '360px', }); dialog @@ -201,7 +199,6 @@ export class TokenComponent implements OnInit, OnDestroy { const dialog = this.dialog.open(ConfirmationDialogComponent, { data: payload, - width: '360px', }); dialog.afterClosed().subscribe((result) => { @@ -219,7 +216,6 @@ export class TokenComponent implements OnInit, OnDestroy { const dialog = this.dialog.open(ConnectionManagerDialogComponent, { data: payload, - width: '360px', }); dialog From 8777585fa12269447aca29d4c353eac617c2aa17 Mon Sep 17 00:00:00 2001 From: manuelwedler Date: Fri, 23 Oct 2020 09:56:36 +0200 Subject: [PATCH 02/17] Query settings endpoint from API --- src/app/models/settings.ts | 3 +++ src/app/services/raiden.service.spec.ts | 21 +++++++++++++++++++++ src/app/services/raiden.service.ts | 5 +++++ 3 files changed, 29 insertions(+) create mode 100644 src/app/models/settings.ts diff --git a/src/app/models/settings.ts b/src/app/models/settings.ts new file mode 100644 index 00000000..cf37e40a --- /dev/null +++ b/src/app/models/settings.ts @@ -0,0 +1,3 @@ +export interface Settings { + readonly pathfinding_service_address: string; +} diff --git a/src/app/services/raiden.service.spec.ts b/src/app/services/raiden.service.spec.ts index eb2ac38f..cf139cfe 100644 --- a/src/app/services/raiden.service.spec.ts +++ b/src/app/services/raiden.service.spec.ts @@ -37,6 +37,7 @@ import { ErrorHandlingInterceptor } from '../interceptors/error-handling.interce import { PaymentEvent } from '../models/payment-event'; import { MockConfig } from '../../testing/mock-config'; import { TokenInfo } from '../models/usertoken'; +import { Settings } from '../models/settings'; describe('RaidenService', () => { const token = createToken(); @@ -1560,4 +1561,24 @@ describe('RaidenService', () => { statusText: '', }); }); + + it('should return the settings', () => { + const settings: Settings = { + pathfinding_service_address: + 'https://pfs.demo001.env.raiden.network', + }; + service + .getSettings() + .subscribe((value) => expect(value).toEqual(settings)); + + const request = mockHttp.expectOne({ + url: `${endpoint}/settings`, + method: 'GET', + }); + + request.flush(settings, { + status: 200, + statusText: '', + }); + }); }); diff --git a/src/app/services/raiden.service.ts b/src/app/services/raiden.service.ts index 83d8e533..512af431 100644 --- a/src/app/services/raiden.service.ts +++ b/src/app/services/raiden.service.ts @@ -52,6 +52,7 @@ import { UiMessage } from '../models/notification'; import { AddressBookService } from './address-book.service'; import { Status } from '../models/status'; import { ContractsInfo } from '../models/contracts-info'; +import { Settings } from '../models/settings'; interface PendingChannelsMap { [tokenAddress: string]: { [partnerAddress: string]: Channel }; @@ -867,6 +868,10 @@ export class RaidenService { ); } + public getSettings(): Observable { + return this.http.get(`${this.raidenConfig.api}/settings`); + } + public getUserToken(tokenAddress: string): UserToken | null { return this.userTokens[tokenAddress]; } From 10c741c19d5e925b8514757d5db89aa8dff5a10c Mon Sep 17 00:00:00 2001 From: manuelwedler Date: Fri, 23 Oct 2020 16:35:38 +0200 Subject: [PATCH 03/17] Query token network address from API --- src/app/services/raiden.service.spec.ts | 17 +++++++++++++++++ src/app/services/raiden.service.ts | 6 ++++++ 2 files changed, 23 insertions(+) diff --git a/src/app/services/raiden.service.spec.ts b/src/app/services/raiden.service.spec.ts index cf139cfe..b43ebfa3 100644 --- a/src/app/services/raiden.service.spec.ts +++ b/src/app/services/raiden.service.spec.ts @@ -1581,4 +1581,21 @@ describe('RaidenService', () => { statusText: '', }); }); + + it('should return the token network address for a token', () => { + const tokenNetworkAddress = createAddress(); + service + .getTokenNetworkAddress(token.address) + .subscribe((value) => expect(value).toEqual(tokenNetworkAddress)); + + const request = mockHttp.expectOne({ + url: `${endpoint}/tokens/${token.address}`, + method: 'GET', + }); + + request.flush(`"${tokenNetworkAddress}"`, { + status: 200, + statusText: '', + }); + }); }); diff --git a/src/app/services/raiden.service.ts b/src/app/services/raiden.service.ts index 512af431..7c359734 100644 --- a/src/app/services/raiden.service.ts +++ b/src/app/services/raiden.service.ts @@ -872,6 +872,12 @@ export class RaidenService { return this.http.get(`${this.raidenConfig.api}/settings`); } + public getTokenNetworkAddress(tokenAddress: string): Observable { + return this.http.get( + `${this.raidenConfig.api}/tokens/${tokenAddress}` + ); + } + public getUserToken(tokenAddress: string): UserToken | null { return this.userTokens[tokenAddress]; } From 08920caed66e84c20fb75966355bed00b96721b7 Mon Sep 17 00:00:00 2001 From: manuelwedler Date: Mon, 26 Oct 2020 17:06:57 +0100 Subject: [PATCH 04/17] Make TokenInputComponent more flexible and adjust layout --- .../address-input.component.html | 7 ++- .../address-input.component.scss | 2 +- .../open-dialog/open-dialog.component.scss | 1 + .../token-input/token-input.component.html | 50 ++++++++++++------- .../token-input/token-input.component.scss | 31 +++++++++--- .../token-input/token-input.component.ts | 7 +-- .../raiden-icons/raiden-icons.module.ts | 1 + src/assets/icons/help.svg | 5 ++ 8 files changed, 74 insertions(+), 30 deletions(-) create mode 100644 src/assets/icons/help.svg diff --git a/src/app/components/address-input/address-input.component.html b/src/app/components/address-input/address-input.component.html index 13f170b6..891a1558 100644 --- a/src/app/components/address-input/address-input.component.html +++ b/src/app/components/address-input/address-input.component.html @@ -32,7 +32,12 @@ -
+
- +
+
+ -
- {{ selectedToken?.symbol }} +
+ {{ selectedToken?.symbol }} +
-
-
+
+
diff --git a/src/app/components/token-input/token-input.component.scss b/src/app/components/token-input/token-input.component.scss index 0ed22194..f662104f 100644 --- a/src/app/components/token-input/token-input.component.scss +++ b/src/app/components/token-input/token-input.component.scss @@ -1,29 +1,46 @@ @import '../../../sass/fonts'; @import '../../../sass/colors'; -.input { +.amount { z-index: 1; position: relative; + &__help { + color: $action-blue; + } +} + +.input { + position: relative; + border-radius: 25px; + background-color: $light-grey; + height: 32px; + color: $black; + &__field { - border-radius: 25px; - background-color: $light-grey; + width: 100%; + min-width: 0; + padding: 0 52px 0 18px; + background: none; border: none; box-shadow: none; font-family: $main-font; - padding: 0 18px; - height: 32px; font-size: 13px; line-height: 15px; letter-spacing: 0.35px; - color: $black; } &__symbol { - margin: 0 auto; + position: absolute; + right: 0; + margin-right: 10px; font-size: 13px; line-height: 15px; letter-spacing: 0.35px; + max-width: 40px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } } diff --git a/src/app/components/token-input/token-input.component.ts b/src/app/components/token-input/token-input.component.ts index c636d96c..e0f3c599 100644 --- a/src/app/components/token-input/token-input.component.ts +++ b/src/app/components/token-input/token-input.component.ts @@ -43,6 +43,8 @@ export class TokenInputComponent implements ControlValueAccessor, Validator { @Input() allowZero = false; @Input() infoText = ''; @Input() placeholder = 'Amount'; + @Input() width = '226'; + @Input() showInfoBox = true; @Input() onChainInput = false; @Input() showTransferLimit = false; @ViewChild('input', { static: true }) private inputElement: ElementRef; @@ -117,9 +119,8 @@ export class TokenInputComponent implements ControlValueAccessor, Validator { } isLessThanThreshold(): boolean { - return ( - this.selectedToken?.transferThreshold && - this.selectedToken.transferThreshold.isGreaterThan(this.amount) + return !!this.selectedToken?.transferThreshold?.isGreaterThan( + this.amount ); } diff --git a/src/app/modules/raiden-icons/raiden-icons.module.ts b/src/app/modules/raiden-icons/raiden-icons.module.ts index 2ae29a70..0c67e8f0 100644 --- a/src/app/modules/raiden-icons/raiden-icons.module.ts +++ b/src/app/modules/raiden-icons/raiden-icons.module.ts @@ -36,6 +36,7 @@ export class RaidenIconsModule { 'user', 'down-arrow', 'paste', + 'help', ]; constructor( diff --git a/src/assets/icons/help.svg b/src/assets/icons/help.svg new file mode 100644 index 00000000..d6988029 --- /dev/null +++ b/src/assets/icons/help.svg @@ -0,0 +1,5 @@ + + + + + From e5d66f8047588e5546861cf87d0186eca178d082 Mon Sep 17 00:00:00 2001 From: manuelwedler Date: Mon, 26 Oct 2020 17:15:06 +0100 Subject: [PATCH 05/17] Set a BigNumber value programmatically on TokenInputComponent --- .../token-input/token-input.component.spec.ts | 15 ++++++++++++++- .../token-input/token-input.component.ts | 11 ++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/app/components/token-input/token-input.component.spec.ts b/src/app/components/token-input/token-input.component.spec.ts index 09fff5d3..3eee6277 100644 --- a/src/app/components/token-input/token-input.component.spec.ts +++ b/src/app/components/token-input/token-input.component.spec.ts @@ -14,6 +14,7 @@ import { DecimalPipe } from '../../pipes/decimal.pipe'; import { DisplayDecimalsPipe } from '../../pipes/display-decimals.pipe'; import { BalanceWithSymbolComponent } from '../balance-with-symbol/balance-with-symbol.component'; import { ClipboardModule } from 'ngx-clipboard'; +import { amountToDecimal } from 'app/utils/amount.converter'; describe('TokenInputComponent', () => { let component: TokenInputComponent; @@ -134,7 +135,7 @@ describe('TokenInputComponent', () => { expect(component.errors['tooManyDecimals']).toBe(true); }); - it('should be able to set a value programmatically', () => { + it('should be able to set a string value programmatically', () => { component.writeValue('0.00003'); fixture.detectChanges(); @@ -143,6 +144,18 @@ describe('TokenInputComponent', () => { expect(component.errors).toBeFalsy(); }); + it('should be able to set a BigNumber value programmatically', () => { + const value = new BigNumber(500000000000000); + component.writeValue(value); + fixture.detectChanges(); + + expect(input.value).toBe( + amountToDecimal(value, token.decimals).toFixed() + ); + expect(component.amount.isEqualTo(value)).toBe(true); + expect(component.errors).toBeFalsy(); + }); + it('should not to set a wrongly typed value programmatically', () => { component.writeValue(100); fixture.detectChanges(); diff --git a/src/app/components/token-input/token-input.component.ts b/src/app/components/token-input/token-input.component.ts index e0f3c599..d5bfed91 100644 --- a/src/app/components/token-input/token-input.component.ts +++ b/src/app/components/token-input/token-input.component.ts @@ -91,11 +91,16 @@ export class TokenInputComponent implements ControlValueAccessor, Validator { } writeValue(obj: any) { - if (!obj || typeof obj !== 'string') { + if (!obj) { return; + } else if (typeof obj === 'string') { + this.inputElement.nativeElement.value = obj; + this.onChange(); + } else if (BigNumber.isBigNumber(obj)) { + const value = amountToDecimal(obj, this.decimals).toFixed(); + this.inputElement.nativeElement.value = value; + this.onChange(); } - this.inputElement.nativeElement.value = obj; - this.onChange(); } validate(c: AbstractControl): ValidationErrors | null { From 13c24ea0aaf81c0925d1eed380835f54157c589f Mon Sep 17 00:00:00 2001 From: manuelwedler Date: Wed, 28 Oct 2020 12:58:04 +0100 Subject: [PATCH 06/17] Use UserToken objects as form control value of TokenNetworkSelectorComponent --- .../channel-list.component.spec.ts | 8 +-- .../channel-list/channel-list.component.ts | 6 +-- .../contact-actions.component.spec.ts | 8 +-- .../contact-actions.component.ts | 4 +- .../open-dialog/open-dialog.component.html | 1 - .../open-dialog/open-dialog.component.spec.ts | 4 +- .../open-dialog/open-dialog.component.ts | 52 ++++++++++--------- .../payment-dialog.component.html | 1 - .../payment-dialog.component.spec.ts | 10 ++-- .../payment-dialog.component.ts | 46 +++++++++------- .../payment-identifier-input.component.ts | 2 +- .../token-network-selector.component.spec.ts | 19 ++----- .../token-network-selector.component.ts | 21 +++----- .../components/token/token.component.spec.ts | 12 ++--- src/app/components/token/token.component.ts | 4 +- 15 files changed, 93 insertions(+), 105 deletions(-) diff --git a/src/app/components/channel-list/channel-list.component.spec.ts b/src/app/components/channel-list/channel-list.component.spec.ts index 46da9492..d1e27b34 100644 --- a/src/app/components/channel-list/channel-list.component.spec.ts +++ b/src/app/components/channel-list/channel-list.component.spec.ts @@ -214,7 +214,7 @@ describe('ChannelListComponent', () => { const dialogSpy = spyOn(dialog, 'open').and.callThrough(); const dialogResult: OpenDialogResult = { - tokenAddress: token1.address, + token: token1, partnerAddress: createAddress(), balance: new BigNumber(1000), settleTimeout: raidenConfig.config.settle_timeout, @@ -226,7 +226,7 @@ describe('ChannelListComponent', () => { clickElement(fixture.debugElement, '#open-channel'); const payload: OpenDialogPayload = { - tokenAddress: '', + token: undefined, defaultSettleTimeout: raidenConfig.config.settle_timeout, revealTimeout: raidenConfig.config.reveal_timeout, }; @@ -236,7 +236,7 @@ describe('ChannelListComponent', () => { }); expect(openSpy).toHaveBeenCalledTimes(1); expect(openSpy).toHaveBeenCalledWith( - dialogResult.tokenAddress, + dialogResult.token.address, dialogResult.partnerAddress, dialogResult.settleTimeout, dialogResult.balance @@ -272,7 +272,7 @@ describe('ChannelListComponent', () => { clickElement(fixture.debugElement, '#open-channel'); const payload: OpenDialogPayload = { - tokenAddress: token1.address, + token: token1, defaultSettleTimeout: raidenConfig.config.settle_timeout, revealTimeout: raidenConfig.config.reveal_timeout, }; diff --git a/src/app/components/channel-list/channel-list.component.ts b/src/app/components/channel-list/channel-list.component.ts index 8d97a054..8bfdeb21 100644 --- a/src/app/components/channel-list/channel-list.component.ts +++ b/src/app/components/channel-list/channel-list.component.ts @@ -7,7 +7,7 @@ import { AfterViewInit, } from '@angular/core'; import { Channel } from '../../models/channel'; -import { EMPTY, Subject, Observable, fromEvent } from 'rxjs'; +import { EMPTY, Subject, fromEvent } from 'rxjs'; import { ChannelPollingService } from '../../services/channel-polling.service'; import { amountToDecimal } from '../../utils/amount.converter'; import { UserToken } from '../../models/usertoken'; @@ -120,7 +120,7 @@ export class ChannelListComponent implements OnInit, OnDestroy, AfterViewInit { const rdnConfig = this.raidenConfig.config; const payload: OpenDialogPayload = { - tokenAddress: this.selectedToken ? this.selectedToken.address : '', + token: this.selectedToken, revealTimeout: rdnConfig.reveal_timeout, defaultSettleTimeout: rdnConfig.settle_timeout, }; @@ -138,7 +138,7 @@ export class ChannelListComponent implements OnInit, OnDestroy, AfterViewInit { } return this.raidenService.openChannel( - result.tokenAddress, + result.token.address, result.partnerAddress, result.settleTimeout, result.balance diff --git a/src/app/components/contact/contact-actions/contact-actions.component.spec.ts b/src/app/components/contact/contact-actions/contact-actions.component.spec.ts index 99e8b3d3..ba75d0da 100644 --- a/src/app/components/contact/contact-actions/contact-actions.component.spec.ts +++ b/src/app/components/contact/contact-actions/contact-actions.component.spec.ts @@ -113,7 +113,7 @@ describe('ContactActionsComponent', () => { it('should open payment dialog', () => { const dialogSpy = spyOn(dialog, 'open').and.callThrough(); const dialogResult: PaymentDialogPayload = { - tokenAddress: token.address, + token: token, targetAddress: contact.address, amount: new BigNumber(10), paymentIdentifier: undefined, @@ -126,7 +126,7 @@ describe('ContactActionsComponent', () => { clickElement(fixture.debugElement, '#transfer'); const payload: PaymentDialogPayload = { - tokenAddress: '', + token: undefined, targetAddress: contact.address, amount: undefined, }; @@ -136,7 +136,7 @@ describe('ContactActionsComponent', () => { }); expect(initiatePaymentSpy).toHaveBeenCalledTimes(1); expect(initiatePaymentSpy).toHaveBeenCalledWith( - dialogResult.tokenAddress, + dialogResult.token.address, dialogResult.targetAddress, dialogResult.amount, dialogResult.paymentIdentifier @@ -172,7 +172,7 @@ describe('ContactActionsComponent', () => { clickElement(fixture.debugElement, '#transfer'); const payload: PaymentDialogPayload = { - tokenAddress: connectedToken.address, + token: connectedToken, targetAddress: contact.address, amount: undefined, }; diff --git a/src/app/components/contact/contact-actions/contact-actions.component.ts b/src/app/components/contact/contact-actions/contact-actions.component.ts index 0b8ffbc1..bccfc0c0 100644 --- a/src/app/components/contact/contact-actions/contact-actions.component.ts +++ b/src/app/components/contact/contact-actions/contact-actions.component.ts @@ -93,7 +93,7 @@ export class ContactActionsComponent implements OnInit, OnDestroy { this.actionClicked.emit(true); const payload: PaymentDialogPayload = { - tokenAddress: this.selectedToken ? this.selectedToken.address : '', + token: this.selectedToken, targetAddress: this.contact.address, amount: undefined, }; @@ -111,7 +111,7 @@ export class ContactActionsComponent implements OnInit, OnDestroy { } return this.raidenService.initiatePayment( - result.tokenAddress, + result.token.address, result.targetAddress, result.amount, result.paymentIdentifier diff --git a/src/app/components/open-dialog/open-dialog.component.html b/src/app/components/open-dialog/open-dialog.component.html index bfbbb015..920c6efe 100644 --- a/src/app/components/open-dialog/open-dialog.component.html +++ b/src/app/components/open-dialog/open-dialog.component.html @@ -8,7 +8,6 @@ > diff --git a/src/app/components/open-dialog/open-dialog.component.spec.ts b/src/app/components/open-dialog/open-dialog.component.spec.ts index 074900bd..c1041e3e 100644 --- a/src/app/components/open-dialog/open-dialog.component.spec.ts +++ b/src/app/components/open-dialog/open-dialog.component.spec.ts @@ -68,7 +68,7 @@ describe('OpenDialogComponent', () => { beforeEach( waitForAsync(() => { const payload: OpenDialogPayload = { - tokenAddress: token.address, + token: token, defaultSettleTimeout: defaultSettleTimeout, revealTimeout: revealTimeout, }; @@ -136,7 +136,7 @@ describe('OpenDialogComponent', () => { expect(closeSpy).toHaveBeenCalledTimes(1); expect(closeSpy).toHaveBeenCalledWith({ - tokenAddress: token.address, + token: token, partnerAddress: addressInput, settleTimeout: defaultSettleTimeout, balance: new BigNumber(amountInput), diff --git a/src/app/components/open-dialog/open-dialog.component.ts b/src/app/components/open-dialog/open-dialog.component.ts index e140a141..08ae31d7 100644 --- a/src/app/components/open-dialog/open-dialog.component.ts +++ b/src/app/components/open-dialog/open-dialog.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject, ViewChild, OnDestroy } from '@angular/core'; +import { Component, Inject, ViewChild, OnDestroy, OnInit } from '@angular/core'; import { AbstractControl, FormBuilder, @@ -14,19 +14,19 @@ import BigNumber from 'bignumber.js'; import { Animations } from '../../animations/animations'; import { TokenPollingService } from '../../services/token-polling.service'; import { Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { switchMap, takeUntil, tap } from 'rxjs/operators'; export interface OpenDialogPayload { - readonly tokenAddress: string; + readonly token: UserToken; readonly defaultSettleTimeout: number; readonly revealTimeout: number; } export interface OpenDialogResult { - tokenAddress: string; - partnerAddress: string; - settleTimeout: number; - balance: BigNumber; + readonly token: UserToken; + readonly partnerAddress: string; + readonly settleTimeout: number; + readonly balance: BigNumber; } @Component({ @@ -35,7 +35,7 @@ export interface OpenDialogResult { styleUrls: ['./open-dialog.component.scss'], animations: Animations.fallDown, }) -export class OpenDialogComponent implements OnDestroy { +export class OpenDialogComponent implements OnInit, OnDestroy { @ViewChild(TokenInputComponent, { static: true }) private tokenInput: TokenInputComponent; @@ -53,7 +53,7 @@ export class OpenDialogComponent implements OnDestroy { this.revealTimeout = data.revealTimeout; this.form = this.fb.group({ address: ['', Validators.required], - token: [data.tokenAddress, Validators.required], + token: [data.token, Validators.required], amount: ['', Validators.required], settle_timeout: [ data.defaultSettleTimeout, @@ -66,6 +66,23 @@ export class OpenDialogComponent implements OnDestroy { }); } + ngOnInit() { + this.form.controls.token.valueChanges + .pipe( + tap((token) => (this.tokenInput.selectedToken = token)), + switchMap((token) => + this.tokenPollingService.getTokenUpdates( + token?.address ?? '' + ) + ), + takeUntil(this.ngUnsubscribe) + ) + .subscribe((updatedToken) => { + this.tokenInput.maxAmount = updatedToken?.balance; + }); + this.form.controls.token.updateValueAndValidity(); + } + ngOnDestroy() { this.ngUnsubscribe.next(); this.ngUnsubscribe.complete(); @@ -74,7 +91,7 @@ export class OpenDialogComponent implements OnDestroy { accept() { const value = this.form.value; const result: OpenDialogResult = { - tokenAddress: value.token, + token: value.token, partnerAddress: value.address, settleTimeout: value.settle_timeout, balance: value.amount, @@ -87,21 +104,6 @@ export class OpenDialogComponent implements OnDestroy { this.dialogRef.close(); } - tokenNetworkSelected(token: UserToken) { - this.tokenInput.selectedToken = token; - this.subscribeToTokenUpdates(token.address); - } - - subscribeToTokenUpdates(tokenAddress: string) { - this.ngUnsubscribe.next(); - this.tokenPollingService - .getTokenUpdates(tokenAddress) - .pipe(takeUntil(this.ngUnsubscribe)) - .subscribe((updatedToken: UserToken) => { - this.tokenInput.maxAmount = updatedToken.balance; - }); - } - private settleTimeoutValidator(): ValidatorFn { return (control: AbstractControl): ValidationErrors => { const value = parseInt(control.value, 10); diff --git a/src/app/components/payment-dialog/payment-dialog.component.html b/src/app/components/payment-dialog/payment-dialog.component.html index 969a488c..a4503728 100644 --- a/src/app/components/payment-dialog/payment-dialog.component.html +++ b/src/app/components/payment-dialog/payment-dialog.component.html @@ -8,7 +8,6 @@ > diff --git a/src/app/components/payment-dialog/payment-dialog.component.spec.ts b/src/app/components/payment-dialog/payment-dialog.component.spec.ts index 88c9aea2..459585bd 100644 --- a/src/app/components/payment-dialog/payment-dialog.component.spec.ts +++ b/src/app/components/payment-dialog/payment-dialog.component.spec.ts @@ -104,7 +104,7 @@ describe('PaymentDialogComponent', () => { beforeEach( waitForAsync(() => { const payload: PaymentDialogPayload = { - tokenAddress: '', + token: undefined, amount: undefined, targetAddress: '', }; @@ -185,7 +185,7 @@ describe('PaymentDialogComponent', () => { expect(closeSpy).toHaveBeenCalledTimes(1); expect(closeSpy).toHaveBeenCalledWith({ - tokenAddress: token.address, + token: token, targetAddress: addressInput, amount: new BigNumber(amountInput), paymentIdentifier: undefined, @@ -202,7 +202,7 @@ describe('PaymentDialogComponent', () => { expect(closeSpy).toHaveBeenCalledTimes(1); expect(closeSpy).toHaveBeenCalledWith({ - tokenAddress: token.address, + token: token, targetAddress: addressInput, amount: new BigNumber(amountInput), paymentIdentifier: new BigNumber(identifierInput), @@ -245,7 +245,7 @@ describe('PaymentDialogComponent', () => { }); expect(close).toHaveBeenCalledTimes(1); expect(close).toHaveBeenCalledWith({ - tokenAddress: token.address, + token: token, targetAddress: addressInput, amount: new BigNumber(amountInput), paymentIdentifier: new BigNumber(identifierInput), @@ -317,7 +317,7 @@ describe('PaymentDialogComponent', () => { expect(dialogSpy).toHaveBeenCalledTimes(0); expect(closeSpy).toHaveBeenCalledTimes(1); expect(closeSpy).toHaveBeenCalledWith({ - tokenAddress: token.address, + token: token, targetAddress: addressInput, amount: new BigNumber(amountInput), paymentIdentifier: differentIdentifier, diff --git a/src/app/components/payment-dialog/payment-dialog.component.ts b/src/app/components/payment-dialog/payment-dialog.component.ts index b7697bfb..8d3e8e99 100644 --- a/src/app/components/payment-dialog/payment-dialog.component.ts +++ b/src/app/components/payment-dialog/payment-dialog.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { Component, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { MAT_DIALOG_DATA, @@ -8,8 +8,8 @@ import { import { UserToken } from '../../models/usertoken'; import BigNumber from 'bignumber.js'; import { PendingTransferPollingService } from '../../services/pending-transfer-polling.service'; -import { first, switchMap, map } from 'rxjs/operators'; -import { of, Observable } from 'rxjs'; +import { first, switchMap, map, takeUntil } from 'rxjs/operators'; +import { of, Observable, Subject } from 'rxjs'; import { ConfirmationDialogComponent, ConfirmationDialogPayload, @@ -19,10 +19,10 @@ import { AddressBookService } from '../../services/address-book.service'; import { TokenInputComponent } from '../token-input/token-input.component'; export interface PaymentDialogPayload { - tokenAddress: string; - targetAddress: string; - amount: BigNumber; - paymentIdentifier?: BigNumber; + readonly token: UserToken; + readonly targetAddress: string; + readonly amount: BigNumber; + readonly paymentIdentifier?: BigNumber; } @Component({ @@ -30,12 +30,13 @@ export interface PaymentDialogPayload { templateUrl: './payment-dialog.component.html', styleUrls: ['./payment-dialog.component.css'], }) -export class PaymentDialogComponent implements OnInit { +export class PaymentDialogComponent implements OnInit, OnDestroy { @ViewChild(TokenInputComponent, { static: true }) private tokenInput: TokenInputComponent; form: FormGroup; - selectedToken: UserToken; + + private ngUnsubscribe = new Subject(); constructor( @Inject(MAT_DIALOG_DATA) data: PaymentDialogPayload, @@ -48,19 +49,31 @@ export class PaymentDialogComponent implements OnInit { this.form = this.fb.group({ target_address: [data.targetAddress, Validators.required], amount: ['', Validators.required], - token: [data.tokenAddress, Validators.required], + token: [data.token, Validators.required], payment_identifier: '', }); } - ngOnInit() {} + ngOnInit() { + this.form.controls.token.valueChanges + .pipe(takeUntil(this.ngUnsubscribe)) + .subscribe((token) => { + this.tokenInput.selectedToken = token; + }); + this.form.controls.token.updateValueAndValidity(); + } + + ngOnDestroy() { + this.ngUnsubscribe.next(); + this.ngUnsubscribe.complete(); + } accept() { const value = this.form.value; const paymentIdentifier = value['payment_identifier']; const payload: PaymentDialogPayload = { - tokenAddress: value['token'], + token: value['token'], targetAddress: value['target_address'], amount: value['amount'], paymentIdentifier: @@ -83,11 +96,6 @@ export class PaymentDialogComponent implements OnInit { this.dialogRef.close(); } - tokenNetworkSelected(token: UserToken) { - this.selectedToken = token; - this.tokenInput.selectedToken = token; - } - private checkPendingPayments( payload: PaymentDialogPayload ): Observable { @@ -97,7 +105,7 @@ export class PaymentDialogComponent implements OnInit { const samePendingPayment = pendingTransfers.find( (pendingTransfer) => pendingTransfer.token_address === - payload.tokenAddress && + payload.token.address && pendingTransfer.role === 'initiator' && pendingTransfer.locked_amount.isEqualTo( payload.amount @@ -121,7 +129,7 @@ export class PaymentDialogComponent implements OnInit { private confirmDuplicatePayment( payload: PaymentDialogPayload ): Observable { - const token = this.selectedToken; + const token = this.form.value['token']; const formattedAmount = amountToDecimal( payload.amount, token.decimals diff --git a/src/app/components/payment-identifier-input/payment-identifier-input.component.ts b/src/app/components/payment-identifier-input/payment-identifier-input.component.ts index dd3f8653..82aec5ae 100644 --- a/src/app/components/payment-identifier-input/payment-identifier-input.component.ts +++ b/src/app/components/payment-identifier-input/payment-identifier-input.component.ts @@ -44,7 +44,7 @@ export class PaymentIdentifierInputComponent showInputField = false; private propagateTouched = () => {}; - private propagateChange = (amount: BigNumber) => {}; + private propagateChange = (identifier: BigNumber) => {}; constructor(private changeDetectorRef: ChangeDetectorRef) {} diff --git a/src/app/components/token-network-selector/token-network-selector.component.spec.ts b/src/app/components/token-network-selector/token-network-selector.component.spec.ts index fcf0fff0..22cea20c 100644 --- a/src/app/components/token-network-selector/token-network-selector.component.spec.ts +++ b/src/app/components/token-network-selector/token-network-selector.component.spec.ts @@ -99,10 +99,8 @@ describe('TokenNetworkSelectorComponent', () => { it('should select a token network', () => { const changeSpy = jasmine.createSpy('onChange'); const touchedSpy = jasmine.createSpy('onTouched'); - const tokenChangedSpy = jasmine.createSpy('tokenChanged'); component.registerOnChange(changeSpy); component.registerOnTouched(touchedSpy); - component.tokenChanged.subscribe(tokenChangedSpy); fixture.detectChanges(); mockOpenMatSelect(fixture.debugElement); @@ -114,27 +112,18 @@ describe('TokenNetworkSelectorComponent', () => { expect(component.value).toBe(connectedToken); expect(touchedSpy).toHaveBeenCalled(); expect(changeSpy).toHaveBeenCalledTimes(1); - expect(changeSpy).toHaveBeenCalledWith(connectedToken.address); - expect(tokenChangedSpy).toHaveBeenCalledTimes(1); - expect(tokenChangedSpy).toHaveBeenCalledWith(connectedToken); + expect(changeSpy).toHaveBeenCalledWith(connectedToken); }); it('should be able to set a value programmatically', () => { - const raidenService = TestBed.inject(RaidenService); - spyOn(raidenService, 'getUserToken').and.returnValue(notOwnedToken); - const address = notOwnedToken.address; - component.writeValue(address); + component.writeValue(notOwnedToken); fixture.detectChanges(); - expect(component.value).toBe(notOwnedToken); }); - it('should not to set an unregistered token programmatically', () => { - const raidenService = TestBed.inject(RaidenService); - spyOn(raidenService, 'getUserToken').and.returnValue(undefined); - component.writeValue(createAddress()); + it('should not to set a non token object programmatically', () => { + component.writeValue(''); fixture.detectChanges(); - expect(component.value).toBe(undefined); }); diff --git a/src/app/components/token-network-selector/token-network-selector.component.ts b/src/app/components/token-network-selector/token-network-selector.component.ts index 48e7da7a..9a88f746 100644 --- a/src/app/components/token-network-selector/token-network-selector.component.ts +++ b/src/app/components/token-network-selector/token-network-selector.component.ts @@ -1,10 +1,4 @@ -import { - Component, - EventEmitter, - forwardRef, - Output, - Input, -} from '@angular/core'; +import { Component, forwardRef, Input } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { Observable, EMPTY } from 'rxjs'; import { map, share, mergeMap } from 'rxjs/operators'; @@ -36,13 +30,12 @@ export class TokenNetworkSelectorComponent implements ControlValueAccessor { @Input() placeholder = 'Token Network'; @Input() selectorClass = ''; @Input() panelClass = ''; - @Output() tokenChanged = new EventEmitter(); value: UserToken; tokens$: Observable; private propagateTouched = () => {}; - private propagateChange = (tokenAddress: string) => {}; + private propagateChange = (token: UserToken) => {}; constructor( private tokenPollingService: TokenPollingService, @@ -69,17 +62,15 @@ export class TokenNetworkSelectorComponent implements ControlValueAccessor { } writeValue(obj: any) { - const token = this.raidenService.getUserToken(obj); - if (!token) { + if (!obj || !obj.address) { return; } - this.value = token; - this.tokenChanged.emit(token); + this.value = obj; + this.onChange(obj); } onChange(value: UserToken) { - this.propagateChange(value?.address); - this.tokenChanged.emit(value); + this.propagateChange(value); if (this.setSelectedToken) { this.selectedTokenService.setToken(value); } diff --git a/src/app/components/token/token.component.spec.ts b/src/app/components/token/token.component.spec.ts index 5efbdbda..04c85cce 100644 --- a/src/app/components/token/token.component.spec.ts +++ b/src/app/components/token/token.component.spec.ts @@ -144,7 +144,7 @@ describe('TokenComponent', () => { it('should open payment dialog with no token network selected', () => { const dialogSpy = spyOn(dialog, 'open').and.callThrough(); const dialogResult: PaymentDialogPayload = { - tokenAddress: connectedToken.address, + token: connectedToken, targetAddress: createAddress(), amount: new BigNumber(10), paymentIdentifier: undefined, @@ -157,7 +157,7 @@ describe('TokenComponent', () => { clickElement(fixture.debugElement, '#transfer'); const payload: PaymentDialogPayload = { - tokenAddress: '', + token: undefined, targetAddress: '', amount: undefined, }; @@ -167,7 +167,7 @@ describe('TokenComponent', () => { }); expect(initiatePaymentSpy).toHaveBeenCalledTimes(1); expect(initiatePaymentSpy).toHaveBeenCalledWith( - dialogResult.tokenAddress, + dialogResult.token.address, dialogResult.targetAddress, dialogResult.amount, dialogResult.paymentIdentifier @@ -258,7 +258,7 @@ describe('TokenComponent', () => { it('should open payment dialog with token network selected', () => { const dialogSpy = spyOn(dialog, 'open').and.callThrough(); const dialogResult: PaymentDialogPayload = { - tokenAddress: connectedToken.address, + token: connectedToken, targetAddress: createAddress(), amount: new BigNumber(10), paymentIdentifier: undefined, @@ -271,7 +271,7 @@ describe('TokenComponent', () => { clickElement(fixture.debugElement, '#transfer'); const payload: PaymentDialogPayload = { - tokenAddress: connectedToken.address, + token: connectedToken, targetAddress: '', amount: undefined, }; @@ -281,7 +281,7 @@ describe('TokenComponent', () => { }); expect(initiatePaymentSpy).toHaveBeenCalledTimes(1); expect(initiatePaymentSpy).toHaveBeenCalledWith( - dialogResult.tokenAddress, + dialogResult.token.address, dialogResult.targetAddress, dialogResult.amount, dialogResult.paymentIdentifier diff --git a/src/app/components/token/token.component.ts b/src/app/components/token/token.component.ts index 3567e70d..53697ba7 100644 --- a/src/app/components/token/token.component.ts +++ b/src/app/components/token/token.component.ts @@ -110,7 +110,7 @@ export class TokenComponent implements OnInit, OnDestroy { } const payload: PaymentDialogPayload = { - tokenAddress: this.selectedToken?.address ?? '', + token: this.selectedToken, targetAddress: '', amount: undefined, }; @@ -128,7 +128,7 @@ export class TokenComponent implements OnInit, OnDestroy { } return this.raidenService.initiatePayment( - result.tokenAddress, + result.token.address, result.targetAddress, result.amount, result.paymentIdentifier From 2aca12c982463e99007d01c8e9e0c7ca137d64b6 Mon Sep 17 00:00:00 2001 From: manuelwedler Date: Wed, 4 Nov 2020 16:48:41 +0100 Subject: [PATCH 07/17] Add structure of new QuickConnectDialogComponent and fetch pfs suggestions --- src/app/app.module.ts | 4 +- .../address-input.component.scss | 4 +- .../quick-connect-dialog.component.html | 46 +++++ .../quick-connect-dialog.component.scss | 17 ++ .../quick-connect-dialog.component.spec.ts | 24 +++ .../quick-connect-dialog.component.ts | 183 ++++++++++++++++++ .../raiden-dialog.component.scss | 4 - .../token-input/token-input.component.html | 9 +- .../token-input/token-input.component.scss | 6 + .../token-input/token-input.component.ts | 5 + .../error-handling.interceptor.spec.ts | 30 +-- .../error-handling.interceptor.ts | 7 +- src/app/models/connection.ts | 13 ++ src/app/services/raiden.service.spec.ts | 2 +- src/testing/mock-config.ts | 3 +- 15 files changed, 331 insertions(+), 26 deletions(-) create mode 100644 src/app/components/quick-connect-dialog/quick-connect-dialog.component.html create mode 100644 src/app/components/quick-connect-dialog/quick-connect-dialog.component.scss create mode 100644 src/app/components/quick-connect-dialog/quick-connect-dialog.component.spec.ts create mode 100644 src/app/components/quick-connect-dialog/quick-connect-dialog.component.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 7611a979..53507bc8 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -64,6 +64,7 @@ import { SetHeadersInterceptor } from './interceptors/set-headers.interceptor'; import { BalanceWithSymbolComponent } from './components/balance-with-symbol/balance-with-symbol.component'; import { AddressIdenticonComponent } from './components/address-identicon/address-identicon.component'; import { PaymentIdentifierInputComponent } from './components/payment-identifier-input/payment-identifier-input.component'; +import { QuickConnectDialogComponent } from './components/quick-connect-dialog/quick-connect-dialog.component'; const appRoutes: Routes = [ { path: '', redirectTo: '/home', pathMatch: 'full' }, @@ -120,6 +121,7 @@ export function ConfigLoader(raidenConfig: RaidenConfig) { BalanceWithSymbolComponent, AddressIdenticonComponent, PaymentIdentifierInputComponent, + QuickConnectDialogComponent, ], imports: [ RouterModule.forRoot(appRoutes), @@ -144,7 +146,7 @@ export function ConfigLoader(raidenConfig: RaidenConfig) { { provide: HTTP_INTERCEPTORS, useClass: ErrorHandlingInterceptor, - deps: [NotificationService, RaidenService], + deps: [NotificationService, RaidenService, RaidenConfig], multi: true, }, { diff --git a/src/app/components/address-input/address-input.component.scss b/src/app/components/address-input/address-input.component.scss index 3c9a7803..9baac04f 100644 --- a/src/app/components/address-input/address-input.component.scss +++ b/src/app/components/address-input/address-input.component.scss @@ -25,7 +25,9 @@ min-width: 0; &:disabled { - color: $text-grey; + color: $dark-grey; + background: none; + border: 1px solid $dark-grey; } } } diff --git a/src/app/components/quick-connect-dialog/quick-connect-dialog.component.html b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.html new file mode 100644 index 00000000..e120cbb6 --- /dev/null +++ b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.html @@ -0,0 +1,46 @@ + + + + + +
+
+ Top suggestions: {{ suggestions }} +
+ +
+ +
+
+ +
+ Could not fetch any suggestions +
+
+
+
diff --git a/src/app/components/quick-connect-dialog/quick-connect-dialog.component.scss b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.scss new file mode 100644 index 00000000..d7698202 --- /dev/null +++ b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.scss @@ -0,0 +1,17 @@ +@import '../../../sass/colors'; + +.content { + height: 182px; + + &__full { + width: 100%; + height: 100%; + } +} + +.error { + font-size: 13px; + line-height: 15px; + letter-spacing: 0.35px; + color: $red; +} diff --git a/src/app/components/quick-connect-dialog/quick-connect-dialog.component.spec.ts b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.spec.ts new file mode 100644 index 00000000..67fcd663 --- /dev/null +++ b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { QuickConnectDialogComponent } from './quick-connect-dialog.component'; + +describe('QuickConnectDialogComponent', () => { + let component: QuickConnectDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [QuickConnectDialogComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(QuickConnectDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/quick-connect-dialog/quick-connect-dialog.component.ts b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.ts new file mode 100644 index 00000000..c76eda3c --- /dev/null +++ b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.ts @@ -0,0 +1,183 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { Animations } from 'app/animations/animations'; +import { ConnectionChoice, SuggestedConnection } from 'app/models/connection'; +import { UserToken } from 'app/models/usertoken'; +import { ChannelPollingService } from 'app/services/channel-polling.service'; +import { RaidenService } from 'app/services/raiden.service'; +import { TokenPollingService } from 'app/services/token-polling.service'; +import { combineLatest, Observable, Subject, zip } from 'rxjs'; +import { + catchError, + filter, + finalize, + first, + map, + switchMap, + takeUntil, + tap, +} from 'rxjs/operators'; +import { TokenInputComponent } from '../token-input/token-input.component'; + +export interface QuickConnectDialogPayload { + readonly token: UserToken; +} + +export interface QuickConnectDialogResult { + readonly token: UserToken; + readonly connectionChoices: ConnectionChoice[]; +} + +@Component({ + selector: 'app-quick-connect-dialog', + templateUrl: './quick-connect-dialog.component.html', + styleUrls: ['./quick-connect-dialog.component.scss'], + animations: Animations.stretchInOut, +}) +export class QuickConnectDialogComponent implements OnInit, OnDestroy { + @ViewChild(TokenInputComponent, { static: true }) + private tokenInput: TokenInputComponent; + + form: FormGroup; + initiatedWithoutToken = false; + suggestions: SuggestedConnection[] = []; + loading = false; + pfsError = false; + + private ngUnsubscribe = new Subject(); + + constructor( + @Inject(MAT_DIALOG_DATA) data: QuickConnectDialogPayload, + private dialogRef: MatDialogRef, + private fb: FormBuilder, + private tokenPollingService: TokenPollingService, + private raidenService: RaidenService, + private http: HttpClient, + private channelPollingService: ChannelPollingService + ) { + this.initiatedWithoutToken = !data.token; + this.form = this.fb.group({ + token: [data.token, Validators.required], + totalAmount: ['', Validators.required], + }); + } + + ngOnInit(): void { + this.subscribeToSuggestions(); + this.subscribeToTokenUpdates(); + this.form.controls.token.updateValueAndValidity(); + } + + ngOnDestroy() { + this.ngUnsubscribe.next(); + this.ngUnsubscribe.complete(); + } + + accept() { + const payload: QuickConnectDialogResult = { + token: this.form.value.token, + connectionChoices: undefined, // TODO + }; + this.dialogRef.close(payload); + } + + cancel() { + this.dialogRef.close(); + } + + private subscribeToSuggestions() { + const pathfindingServiceUrl$ = this.raidenService + .getSettings() + .pipe(map((settings) => settings.pathfinding_service_address)); + const tokenValueChange$: Observable = this.form.controls + .token.valueChanges; + const tokenNetworkAddress$ = tokenValueChange$.pipe( + filter((token) => !!token), + tap(() => { + this.loading = true; + this.resetError(); + }), + switchMap((token) => + this.raidenService.getTokenNetworkAddress(token.address) + ) + ); + + const suggestions$ = combineLatest([ + pathfindingServiceUrl$, + tokenNetworkAddress$, + ]).pipe( + switchMap(([pathfindingServiceUrl, tokenNetworkAddress]) => + this.http.get( + `${pathfindingServiceUrl}/api/v1/${tokenNetworkAddress}/suggest_partner` + ) + ) + ); + const allChannels$ = this.channelPollingService.channels$.pipe(first()); + const existingChannels$ = combineLatest([ + tokenValueChange$, + allChannels$, + ]).pipe( + map(([token, channels]) => + channels.filter( + (channel) => + channel.state !== 'settled' && + channel.token_address === token.address + ) + ) + ); + + zip(suggestions$, existingChannels$) + .pipe( + map(([suggestions, channels]) => + suggestions.filter( + (suggestion) => + !channels.find( + (channel) => + channel.partner_address === + suggestion.address + ) + ) + ), + catchError((error, caught) => { + this.showError(); + return caught; + }), + tap(() => (this.loading = false)), + takeUntil(this.ngUnsubscribe) + ) + .subscribe((suggestions) => { + if (suggestions.length === 0) { + this.showError(); + } + this.suggestions = suggestions; + }); + } + + private subscribeToTokenUpdates() { + this.form.controls.token.valueChanges + .pipe( + tap((token) => (this.tokenInput.selectedToken = token)), + switchMap((token) => + this.tokenPollingService.getTokenUpdates( + token?.address ?? '' + ) + ), + takeUntil(this.ngUnsubscribe) + ) + .subscribe((updatedToken) => { + this.tokenInput.maxAmount = updatedToken?.balance; + }); + } + + private showError() { + this.pfsError = true; + this.form.controls.totalAmount.disable(); + } + + private resetError() { + this.pfsError = false; + this.form.controls.totalAmount.enable(); + } +} diff --git a/src/app/components/raiden-dialog/raiden-dialog.component.scss b/src/app/components/raiden-dialog/raiden-dialog.component.scss index 23979e81..bc10b7ac 100644 --- a/src/app/components/raiden-dialog/raiden-dialog.component.scss +++ b/src/app/components/raiden-dialog/raiden-dialog.component.scss @@ -22,10 +22,6 @@ &--black { color: $white; - - &:disabled { - color: $dark-grey; - } } } diff --git a/src/app/components/token-input/token-input.component.html b/src/app/components/token-input/token-input.component.html index 2c2c1398..b9905ee1 100644 --- a/src/app/components/token-input/token-input.component.html +++ b/src/app/components/token-input/token-input.component.html @@ -1,6 +1,7 @@
-
+
diff --git a/src/app/components/token-input/token-input.component.scss b/src/app/components/token-input/token-input.component.scss index f662104f..63edc56f 100644 --- a/src/app/components/token-input/token-input.component.scss +++ b/src/app/components/token-input/token-input.component.scss @@ -17,6 +17,12 @@ height: 32px; color: $black; + &--disabled { + color: $dark-grey; + background: none; + border: 1px solid $dark-grey; + } + &__field { width: 100%; min-width: 0; diff --git a/src/app/components/token-input/token-input.component.ts b/src/app/components/token-input/token-input.component.ts index d5bfed91..399afe7c 100644 --- a/src/app/components/token-input/token-input.component.ts +++ b/src/app/components/token-input/token-input.component.ts @@ -52,6 +52,7 @@ export class TokenInputComponent implements ControlValueAccessor, Validator { amount: BigNumber; errors: ValidationErrors = { empty: true }; touched = false; + disabled = false; private _selectedToken: UserToken; private _maxAmount: BigNumber; @@ -107,6 +108,10 @@ export class TokenInputComponent implements ControlValueAccessor, Validator { return this.errors; } + setDisabledState(isDisabled: boolean) { + this.disabled = isDisabled; + } + onChange() { this.setAmount(); } diff --git a/src/app/interceptors/error-handling.interceptor.spec.ts b/src/app/interceptors/error-handling.interceptor.spec.ts index d5312101..0710b691 100644 --- a/src/app/interceptors/error-handling.interceptor.spec.ts +++ b/src/app/interceptors/error-handling.interceptor.spec.ts @@ -11,13 +11,14 @@ import { ErrorHandlingInterceptor } from './error-handling.interceptor'; import { TestProviders } from '../../testing/test-providers'; import { NotificationService } from '../services/notification.service'; import { RaidenService } from '../services/raiden.service'; +import { RaidenConfig } from 'app/services/raiden.config'; @Injectable() class MockRequestingService { - constructor(private http: HttpClient) {} + constructor(private http: HttpClient, private raidenConfig: RaidenConfig) {} getData(): Observable { - return this.http.get('localhost:5001/api'); + return this.http.get(this.raidenConfig.api); } } @@ -25,6 +26,7 @@ describe('ErrorHandlingInterceptor', () => { let service: MockRequestingService; let httpMock: HttpTestingController; let notificationService: NotificationService; + let raidenConfig: RaidenConfig; beforeEach(() => { notificationService = jasmine.createSpyObj('NotificationService', [ @@ -39,7 +41,7 @@ describe('ErrorHandlingInterceptor', () => { { provide: HTTP_INTERCEPTORS, useClass: ErrorHandlingInterceptor, - deps: [NotificationService, RaidenService], + deps: [NotificationService, RaidenService, RaidenConfig], multi: true, }, TestProviders.MockRaidenConfigProvider(), @@ -54,6 +56,7 @@ describe('ErrorHandlingInterceptor', () => { service = TestBed.inject(MockRequestingService); httpMock = TestBed.inject(HttpTestingController); notificationService = TestBed.inject(NotificationService); + raidenConfig = TestBed.inject(RaidenConfig); }); it('should handle Raiden API errors', () => { @@ -67,7 +70,7 @@ describe('ErrorHandlingInterceptor', () => { } ); - const request = httpMock.expectOne('localhost:5001/api'); + const request = httpMock.expectOne(raidenConfig.api); const errorBody = { errors: errorMessage, @@ -91,7 +94,7 @@ describe('ErrorHandlingInterceptor', () => { } ); - const request = httpMock.expectOne('localhost:5001/api'); + const request = httpMock.expectOne(raidenConfig.api); const errorBody = { errors: [ @@ -107,8 +110,7 @@ describe('ErrorHandlingInterceptor', () => { }); it('should handle Raiden API errors with no message', () => { - const errorMessage = - 'Http failure response for localhost:5001/api: 400 '; + const errorMessage = `Http failure response for ${raidenConfig.api}: 400 `; service.getData().subscribe( () => { fail('On next should not be called'); @@ -118,7 +120,7 @@ describe('ErrorHandlingInterceptor', () => { } ); - const request = httpMock.expectOne('localhost:5001/api'); + const request = httpMock.expectOne(raidenConfig.api); const errorBody = { errors: '', @@ -140,7 +142,7 @@ describe('ErrorHandlingInterceptor', () => { } ); - let request = httpMock.expectOne('localhost:5001/api'); + let request = httpMock.expectOne(raidenConfig.api); request.flush( {}, @@ -162,7 +164,7 @@ describe('ErrorHandlingInterceptor', () => { expect(error).toBeTruthy('An error is expected'); } ); - request = httpMock.expectOne('localhost:5001/api'); + request = httpMock.expectOne(raidenConfig.api); request.flush( {}, { @@ -186,7 +188,7 @@ describe('ErrorHandlingInterceptor', () => { } ); - let request = httpMock.expectOne('localhost:5001/api'); + let request = httpMock.expectOne(raidenConfig.api); request.flush( {}, @@ -205,7 +207,7 @@ describe('ErrorHandlingInterceptor', () => { expect(error).toBeTruthy('An error is expected'); } ); - request = httpMock.expectOne('localhost:5001/api'); + request = httpMock.expectOne(raidenConfig.api); request.flush( {}, { @@ -236,7 +238,7 @@ describe('ErrorHandlingInterceptor', () => { } ); - let request = httpMock.expectOne('localhost:5001/api'); + let request = httpMock.expectOne(raidenConfig.api); request.flush( {}, @@ -247,7 +249,7 @@ describe('ErrorHandlingInterceptor', () => { ); service.getData().subscribe(); - request = httpMock.expectOne('localhost:5001/api'); + request = httpMock.expectOne(raidenConfig.api); request.flush( {}, { diff --git a/src/app/interceptors/error-handling.interceptor.ts b/src/app/interceptors/error-handling.interceptor.ts index 37294df8..da62a191 100644 --- a/src/app/interceptors/error-handling.interceptor.ts +++ b/src/app/interceptors/error-handling.interceptor.ts @@ -12,12 +12,14 @@ import { catchError, tap } from 'rxjs/operators'; import { NotificationService } from '../services/notification.service'; import { UiMessage } from '../models/notification'; import { RaidenService } from '../services/raiden.service'; +import { RaidenConfig } from 'app/services/raiden.config'; @Injectable() export class ErrorHandlingInterceptor implements HttpInterceptor { constructor( private notificationService: NotificationService, - private raidenService: RaidenService + private raidenService: RaidenService, + private raidenConfig: RaidenConfig ) {} intercept( @@ -28,7 +30,7 @@ export class ErrorHandlingInterceptor implements HttpInterceptor { tap((event) => { if ( event instanceof HttpResponse && - event.url.includes('/api') && + event.url.startsWith(this.raidenConfig.api) && this.notificationService.apiError ) { this.raidenService.attemptRpcConnection(); @@ -45,6 +47,7 @@ export class ErrorHandlingInterceptor implements HttpInterceptor { if ( error instanceof HttpErrorResponse && + error.url.startsWith(this.raidenConfig.api) && (error.status === 504 || error.status === 0) ) { errMsg = 'Could not connect to the Raiden API'; diff --git a/src/app/models/connection.ts b/src/app/models/connection.ts index 49c83e99..c37e474d 100644 --- a/src/app/models/connection.ts +++ b/src/app/models/connection.ts @@ -9,3 +9,16 @@ export interface Connection { export interface Connections { [address: string]: Connection; } + +export interface ConnectionChoice { + readonly partnerAddress: string; + readonly balance: BigNumber; +} + +export interface SuggestedConnection { + readonly address: string; + readonly score: BigNumber; + readonly centrality: BigNumber; + readonly uptime: BigNumber; + readonly capacity: BigNumber; +} diff --git a/src/app/services/raiden.service.spec.ts b/src/app/services/raiden.service.spec.ts index b43ebfa3..4ac02652 100644 --- a/src/app/services/raiden.service.spec.ts +++ b/src/app/services/raiden.service.spec.ts @@ -93,7 +93,7 @@ describe('RaidenService', () => { { provide: HTTP_INTERCEPTORS, useClass: ErrorHandlingInterceptor, - deps: [NotificationService, RaidenService], + deps: [NotificationService, RaidenService, RaidenConfig], multi: true, }, { diff --git a/src/testing/mock-config.ts b/src/testing/mock-config.ts index d79ecf5b..afe08e63 100644 --- a/src/testing/mock-config.ts +++ b/src/testing/mock-config.ts @@ -49,7 +49,8 @@ const mockNetwork = createNetworkMock(); @Injectable() export class MockConfig extends RaidenConfig { - public web3: Web3 = mockProvider.web3; + web3: Web3 = mockProvider.web3; + api: string = 'localhost:5001/api/v1'; constructor() { super(stub(), stub(), mockProvider); From 90250240a8daf2d5def8699635f9915ec131309b Mon Sep 17 00:00:00 2001 From: manuelwedler Date: Thu, 5 Nov 2020 11:23:30 +0100 Subject: [PATCH 08/17] Introduce constant explorerUrl on Network object --- src/app/utils/network-info.spec.ts | 4 ++++ src/app/utils/network-info.ts | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/src/app/utils/network-info.spec.ts b/src/app/utils/network-info.spec.ts index 609f28ac..f4074a93 100644 --- a/src/app/utils/network-info.spec.ts +++ b/src/app/utils/network-info.spec.ts @@ -9,6 +9,7 @@ describe('NetworkInfo', () => { shortName: 'eth', chainId: 1, ensSupported: true, + explorerUrl: 'https://explorer.raiden.network', faucet: undefined, tokenConstants: tokenConstants[1], }); @@ -20,6 +21,7 @@ describe('NetworkInfo', () => { shortName: 'rop', chainId: 3, ensSupported: true, + explorerUrl: 'https://ropsten.explorer.raiden.network', faucet: 'https://faucet.ropsten.be/?${ADDRESS}', }); }); @@ -30,6 +32,7 @@ describe('NetworkInfo', () => { shortName: 'rin', chainId: 4, ensSupported: true, + explorerUrl: 'https://rinkeby.explorer.raiden.network', faucet: 'https://faucet.rinkeby.io/', }); }); @@ -40,6 +43,7 @@ describe('NetworkInfo', () => { shortName: 'gor', chainId: 5, ensSupported: true, + explorerUrl: 'https://goerli.explorer.raiden.network', faucet: 'https://goerli-faucet.slock.it/?address=${ADDRESS}', }); }); diff --git a/src/app/utils/network-info.ts b/src/app/utils/network-info.ts index 1d6f2023..4466a8cb 100644 --- a/src/app/utils/network-info.ts +++ b/src/app/utils/network-info.ts @@ -8,6 +8,7 @@ export class NetworkInfo { shortName: 'eth', chainId: 1, ensSupported: true, + explorerUrl: 'https://explorer.raiden.network', faucet: undefined, tokenConstants: tokenConstants[1], }, @@ -16,6 +17,7 @@ export class NetworkInfo { shortName: 'rop', chainId: 3, ensSupported: true, + explorerUrl: 'https://ropsten.explorer.raiden.network', faucet: 'https://faucet.ropsten.be/?${ADDRESS}', }, { @@ -23,6 +25,7 @@ export class NetworkInfo { shortName: 'rin', chainId: 4, ensSupported: true, + explorerUrl: 'https://rinkeby.explorer.raiden.network', faucet: 'https://faucet.rinkeby.io/', }, { @@ -30,6 +33,7 @@ export class NetworkInfo { shortName: 'gor', chainId: 5, ensSupported: true, + explorerUrl: 'https://goerli.explorer.raiden.network', faucet: 'https://goerli-faucet.slock.it/?address=${ADDRESS}', }, { @@ -64,6 +68,7 @@ export interface Network { readonly shortName: string; readonly chainId: number; readonly ensSupported: boolean; + readonly explorerUrl?: string; readonly faucet?: string; readonly tokenConstants?: TokenInfo[]; } From f38c49f263f377ff516e8b5612e95d8db435ddb2 Mon Sep 17 00:00:00 2001 From: manuelwedler Date: Thu, 12 Nov 2020 10:29:00 +0100 Subject: [PATCH 09/17] Add ConnectionSelectorComponent to complete QuickConnectDialog --- src/app/app.module.ts | 2 + .../connection-selector.component.html | 108 +++++ .../connection-selector.component.scss | 59 +++ .../connection-selector.component.spec.ts | 24 + .../connection-selector.component.ts | 411 ++++++++++++++++++ .../quick-connect-dialog.component.html | 16 +- .../quick-connect-dialog.component.scss | 10 +- .../quick-connect-dialog.component.ts | 16 +- .../token-input/token-input.component.ts | 15 +- src/app/models/connection.ts | 2 +- .../material-components.module.ts | 2 + .../raiden-icons/raiden-icons.module.ts | 1 + src/assets/icons/graph.svg | 3 + src/styles.scss | 46 ++ 14 files changed, 691 insertions(+), 24 deletions(-) create mode 100644 src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.html create mode 100644 src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.scss create mode 100644 src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.spec.ts create mode 100644 src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.ts create mode 100644 src/assets/icons/graph.svg diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 53507bc8..53939e52 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -65,6 +65,7 @@ import { BalanceWithSymbolComponent } from './components/balance-with-symbol/bal import { AddressIdenticonComponent } from './components/address-identicon/address-identicon.component'; import { PaymentIdentifierInputComponent } from './components/payment-identifier-input/payment-identifier-input.component'; import { QuickConnectDialogComponent } from './components/quick-connect-dialog/quick-connect-dialog.component'; +import { ConnectionSelectorComponent } from './components/quick-connect-dialog/connection-selector/connection-selector.component'; const appRoutes: Routes = [ { path: '', redirectTo: '/home', pathMatch: 'full' }, @@ -122,6 +123,7 @@ export function ConfigLoader(raidenConfig: RaidenConfig) { AddressIdenticonComponent, PaymentIdentifierInputComponent, QuickConnectDialogComponent, + ConnectionSelectorComponent, ], imports: [ RouterModule.forRoot(appRoutes), diff --git a/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.html b/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.html new file mode 100644 index 00000000..5c8cbf29 --- /dev/null +++ b/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.html @@ -0,0 +1,108 @@ +
+ + Top {{ choicesForm.length }} suggested connections (best first): + + + One suggested connection: + +
+ + + + + + {{ partnerAddress }} + + + + +
+
+ + Split Equally + + +
+ + The deposits should not be negative + + + The selected token network only supports up to + {{ tokenFormControl.value.decimals }} decimals + + + The deposits should be valid numbers + + + Cannot distribute more than the total deposit + + + No suggestion is selected + +
+
+
diff --git a/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.scss b/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.scss new file mode 100644 index 00000000..b3256120 --- /dev/null +++ b/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.scss @@ -0,0 +1,59 @@ +@import '../../../../sass/colors'; + +.selector { + font-size: 13px; + line-height: 15px; + letter-spacing: 0.35px; + + &__label { + color: $text-grey; + } +} + +.graph-button { + &__icon { + color: $action-blue; + height: 16px !important; + + &--deselected { + color: $dark-grey; + } + } +} + +.address { + width: 50px; + overflow: hidden; + text-overflow: ellipsis; + color: $action-blue; + + &--deselected { + color: $dark-grey; + } + + &--big { + margin-left: 16px; + width: 178px; + } +} + +.info-box { + height: 32px; + + &__equal-button { + text-decoration: underline; + cursor: pointer; + font-weight: 500; + + &:hover { + color: $text-grey; + } + } + + &__error { + width: 234px; + overflow: hidden; + text-overflow: ellipsis; + color: $red; + } +} diff --git a/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.spec.ts b/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.spec.ts new file mode 100644 index 00000000..97035e5c --- /dev/null +++ b/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConnectionSelectorComponent } from './connection-selector.component'; + +fdescribe('ConnectionSelectorComponent', () => { + let component: ConnectionSelectorComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ConnectionSelectorComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ConnectionSelectorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.ts b/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.ts new file mode 100644 index 00000000..4ce09244 --- /dev/null +++ b/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.ts @@ -0,0 +1,411 @@ +import { + AfterViewInit, + Component, + Input, + OnChanges, + OnDestroy, + OnInit, + QueryList, + SimpleChanges, + ViewChildren, +} from '@angular/core'; +import { + AbstractControl, + ControlContainer, + FormArray, + FormBuilder, + FormControl, + ValidatorFn, + ValidationErrors, + Validators, +} from '@angular/forms'; +import { Animations } from 'app/animations/animations'; +import { TokenInputComponent } from 'app/components/token-input/token-input.component'; +import { SuggestedConnection } from 'app/models/connection'; +import { RaidenService } from 'app/services/raiden.service'; +import BigNumber from 'bignumber.js'; +import { combineLatest, Observable, Subject } from 'rxjs'; +import { + auditTime, + debounceTime, + delay, + distinctUntilChanged, + filter, + map, + startWith, + takeUntil, +} from 'rxjs/operators'; + +@Component({ + selector: 'app-connection-selector', + templateUrl: './connection-selector.component.html', + styleUrls: ['./connection-selector.component.scss'], + animations: Animations.fallDown, +}) +export class ConnectionSelectorComponent + implements OnInit, OnChanges, OnDestroy, AfterViewInit { + private static MAX_SUGGESTIONS = 3; + + @ViewChildren(TokenInputComponent) + private tokenInputs: QueryList; + + @Input() private suggestions: SuggestedConnection[] = []; + + choicesForm: FormArray; + explorerUrl$: Observable; + + private ngUnsubscribe = new Subject(); + private unsubscribeFormChanges = new Subject(); + + constructor( + private fb: FormBuilder, + private raidenService: RaidenService, + private controlContainer: ControlContainer + ) {} + + get tokenFormControl(): FormControl { + return this.choicesForm.parent.get('token') as FormControl; + } + + get totalAmountFormControl(): FormControl { + return this.choicesForm.parent.get('totalAmount') as FormControl; + } + + ngOnInit() { + this.choicesForm = this.controlContainer.control as FormArray; + this.choicesForm.setValidators([ + Validators.required, + this.choicesValidator(), + ]); + const token$ = this.tokenFormControl.valueChanges.pipe( + startWith(this.tokenFormControl.value) + ); + this.explorerUrl$ = combineLatest([ + this.raidenService.network$, + token$, + ]).pipe( + map( + ([network, token]) => + `${network.explorerUrl}/tokens/${token.address}` + ) + ); + } + + ngAfterViewInit() { + const token$ = this.tokenFormControl.valueChanges.pipe( + startWith(this.tokenFormControl.value) + ); + combineLatest([this.tokenInputs.changes, token$]) + .pipe(delay(0), takeUntil(this.ngUnsubscribe)) + .subscribe(([changedInput, token]) => { + changedInput.forEach((tokenInput: TokenInputComponent) => { + tokenInput.selectedToken = token; + }); + }); + + const totalAmount$: Observable = this.totalAmountFormControl.valueChanges.pipe( + startWith(this.totalAmountFormControl.value) + ); + combineLatest([this.tokenInputs.changes, totalAmount$]) + .pipe(delay(0), takeUntil(this.ngUnsubscribe)) + .subscribe(([changedInput, totalAmount]) => { + changedInput.forEach((tokenInput: TokenInputComponent) => { + tokenInput.maxAmount = totalAmount; + }); + }); + + totalAmount$ + .pipe( + filter( + (value) => + BigNumber.isBigNumber(value) && + !value.isNaN() && + this.totalAmountFormControl.valid + ), + distinctUntilChanged((prev, curr) => prev.isEqualTo(curr)), + takeUntil(this.ngUnsubscribe) + ) + .subscribe((totalAmount) => { + this.patchFormForTotalAmount(totalAmount); + }); + + this.totalAmountFormControl.statusChanges + .pipe( + distinctUntilChanged(), + filter((status) => status !== 'PENDING'), + takeUntil(this.ngUnsubscribe) + ) + .subscribe((status) => { + if (status === 'VALID') { + this.choicesForm.enable({ + onlySelf: true, + emitEvent: false, + }); + } else { + this.choicesForm.disable({ + onlySelf: true, + emitEvent: false, + }); + } + }); + + setTimeout(() => this.updateForm()); + } + + ngOnDestroy() { + this.ngUnsubscribe.next(); + this.ngUnsubscribe.complete(); + this.unsubscribeFormChanges.complete(); + this.choicesForm.clear(); + } + + ngOnChanges(changes: SimpleChanges) { + if (!changes.suggestions) { + return; + } + this.updateForm(); + } + + hasValue(value: BigNumber) { + return ( + BigNumber.isBigNumber(value) && + !value.isNaN() && + value.isGreaterThan(0) + ); + } + + splitEqually() { + let controlsForDistribution: AbstractControl[]; + const controlsWithValue = this.choicesForm.controls.filter((control) => + this.hasValue(control.get('deposit').value) + ); + if (controlsWithValue.length <= 1) { + controlsForDistribution = this.choicesForm.controls; + } else { + controlsForDistribution = controlsWithValue; + } + + const totalDeposit: BigNumber = this.totalAmountFormControl.value; + const valuePerControl = totalDeposit.dividedToIntegerBy( + controlsForDistribution.length + ); + const remainder = totalDeposit.modulo(controlsForDistribution.length); + controlsForDistribution.forEach((control, index) => { + let newValue = valuePerControl; + if (remainder.isGreaterThan(index)) { + newValue = newValue.plus(1); + } + control.get('deposit').setValue(newValue, { emitEvent: false }); + }); + this.updatePercentages(); + } + + private percentageUpdated( + changedControlIndex: number, + newPercentage: number + ) { + const totalDeposit: BigNumber = this.totalAmountFormControl.value; + const oldValue: BigNumber = this.choicesForm + .at(changedControlIndex) + .get('deposit').value; + const newValue = totalDeposit + .times(newPercentage) + .integerValue(BigNumber.ROUND_CEIL); + this.choicesForm + .at(changedControlIndex) + .get('deposit') + .setValue(newValue, { emitEvent: false }); + this.updateDepositValues(changedControlIndex, newValue, oldValue); + } + + private updateDepositValues( + changedControlIndex: number, + newValue: BigNumber, + oldValue: BigNumber + ) { + const totalDeposit: BigNumber = this.totalAmountFormControl.value; + + const getOtherActiveControls = () => + this.choicesForm.controls.filter( + (control, index) => + this.hasValue(control.get('deposit').value) && + index !== changedControlIndex + ); + const getSumDeposits = (controls: AbstractControl[]) => + controls.reduce( + (accumulator, control) => + control.get('deposit').value.plus(accumulator), + new BigNumber(0) + ) as BigNumber; + + const distributeChange = ( + valueToDistribute: BigNumber, + controls: AbstractControl[], + add: boolean + ) => { + const valueChangePerControl = valueToDistribute.dividedToIntegerBy( + controls.length + ); + const remainder = valueToDistribute.modulo(controls.length); + + controls.forEach((control, index) => { + const depositControl = control.get('deposit'); + const oldControlValue: BigNumber = depositControl.value; + let valueChange = valueChangePerControl; + if (remainder.isGreaterThan(index)) { + valueChange = valueChange.plus(1); + } + + let newControlValue: BigNumber; + if (add) { + newControlValue = oldControlValue.plus(valueChange); + } else { + newControlValue = BigNumber.max( + oldControlValue.minus(valueChange), + 0 + ); + } + depositControl.setValue(newControlValue, { emitEvent: false }); + }); + }; + + let otherActiveControls = getOtherActiveControls(); + const sumOthers = getSumDeposits(otherActiveControls); + const difference = newValue.minus(oldValue); + let changeOthers: BigNumber; + if ( + difference.isGreaterThan(0) && + newValue.plus(sumOthers).isLessThanOrEqualTo(totalDeposit) + ) { + changeOthers = new BigNumber(0); + } else if (difference.isGreaterThan(0)) { + changeOthers = BigNumber.min(difference, sumOthers); + } else { + changeOthers = difference.absoluteValue(); + } + distributeChange( + changeOthers, + otherActiveControls, + difference.isLessThan(0) + ); + + // There are some edge cases where we end up with more deposit in the + // individual fields than the total deposit + let sumAfterChanges = getSumDeposits(this.choicesForm.controls); + while (sumAfterChanges.isGreaterThan(totalDeposit)) { + const valueToReduce = sumAfterChanges.minus(totalDeposit); + otherActiveControls = getOtherActiveControls(); + distributeChange(valueToReduce, otherActiveControls, false); + + sumAfterChanges = getSumDeposits(this.choicesForm.controls); + } + + this.updatePercentages(); + } + + private updatePercentages() { + const totalDeposit: BigNumber = this.totalAmountFormControl.value; + this.choicesForm.controls.forEach((control) => { + const percentage = Number( + control.get('deposit').value.dividedBy(totalDeposit).toFixed(2) + ); + control + .get('percentage') + .setValue(percentage, { emitEvent: false }); + }); + } + + private updateForm() { + if (!this.choicesForm) { + return; + } + + this.unsubscribeFormChanges.next(); + this.choicesForm.clear(); + const visibleSuggestions: SuggestedConnection[] = this.suggestions.slice( + 0, + ConnectionSelectorComponent.MAX_SUGGESTIONS + ); + visibleSuggestions.forEach((suggestion, index) => { + const formGroup = this.fb.group({ + partnerAddress: [suggestion.address, Validators.required], + deposit: [undefined, Validators.required], + percentage: 0, + }); + + this.choicesForm.push(formGroup); + + formGroup + .get('percentage') + .valueChanges.pipe( + auditTime(300), + takeUntil(this.ngUnsubscribe), + takeUntil(this.unsubscribeFormChanges) + ) + .subscribe((newPercentage) => + this.percentageUpdated(index, newPercentage) + ); + formGroup + .get('deposit') + .valueChanges.pipe( + debounceTime(300), + filter( + (value) => + BigNumber.isBigNumber(value) && + !value.isNaN() && + formGroup.get('deposit').valid + ), + distinctUntilChanged((prev, curr) => prev.isEqualTo(curr)), + takeUntil(this.ngUnsubscribe), + takeUntil(this.unsubscribeFormChanges) + ) + .subscribe((newValue) => { + const totalDeposit: BigNumber = this.totalAmountFormControl + .value; + const oldValue = totalDeposit + .times(formGroup.get('percentage').value) + .integerValue(); + this.updateDepositValues(index, newValue, oldValue); + }); + }); + + this.patchFormForTotalAmount(this.totalAmountFormControl.value); + this.totalAmountFormControl.updateValueAndValidity(); + } + + private patchFormForTotalAmount(totalAmount: BigNumber) { + this.choicesForm.controls.forEach((formGroup, index) => { + let deposit: BigNumber; + let percentage = 0; + if (BigNumber.isBigNumber(totalAmount) && !totalAmount.isNaN()) { + const isFirst = index === 0; + deposit = isFirst ? totalAmount : new BigNumber(0); + percentage = isFirst ? 1 : 0; + } + formGroup.patchValue({ deposit, percentage }, { emitEvent: false }); + }); + } + + private choicesValidator(): ValidatorFn { + return (choicesForm: FormArray): ValidationErrors => { + let allDepositsZero = true; + for (let i = 0; i < choicesForm.controls.length; i++) { + const depositControl = this.choicesForm.at(i).get('deposit'); + const error = depositControl.errors; + if (error) { + return error; + } + + allDepositsZero = + allDepositsZero && !this.hasValue(depositControl.value); + } + if (allDepositsZero) { + return { + noSelection: true, + }; + } + + return undefined; + }; + } +} diff --git a/src/app/components/quick-connect-dialog/quick-connect-dialog.component.html b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.html index e120cbb6..e42088f2 100644 --- a/src/app/components/quick-connect-dialog/quick-connect-dialog.component.html +++ b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.html @@ -21,16 +21,18 @@
-
- Top suggestions: {{ suggestions }} -
+ + -
+
-
+
Could not fetch any suggestions
diff --git a/src/app/components/quick-connect-dialog/quick-connect-dialog.component.scss b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.scss index d7698202..4a58e1cf 100644 --- a/src/app/components/quick-connect-dialog/quick-connect-dialog.component.scss +++ b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.scss @@ -1,15 +1,7 @@ @import '../../../sass/colors'; -.content { - height: 182px; - - &__full { - width: 100%; - height: 100%; - } -} - .error { + height: 32px; font-size: 13px; line-height: 15px; letter-spacing: 0.35px; diff --git a/src/app/components/quick-connect-dialog/quick-connect-dialog.component.ts b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.ts index c76eda3c..19746170 100644 --- a/src/app/components/quick-connect-dialog/quick-connect-dialog.component.ts +++ b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.ts @@ -1,6 +1,6 @@ import { HttpClient } from '@angular/common/http'; import { Component, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { Animations } from 'app/animations/animations'; import { ConnectionChoice, SuggestedConnection } from 'app/models/connection'; @@ -11,8 +11,8 @@ import { TokenPollingService } from 'app/services/token-polling.service'; import { combineLatest, Observable, Subject, zip } from 'rxjs'; import { catchError, + delay, filter, - finalize, first, map, switchMap, @@ -61,6 +61,7 @@ export class QuickConnectDialogComponent implements OnInit, OnDestroy { this.form = this.fb.group({ token: [data.token, Validators.required], totalAmount: ['', Validators.required], + choices: this.fb.array([], Validators.required), }); } @@ -76,9 +77,16 @@ export class QuickConnectDialogComponent implements OnInit, OnDestroy { } accept() { + const choices: ConnectionChoice[] = []; + (this.form.get('choices')).controls.forEach((control) => { + choices.push({ + partnerAddress: control.get('partnerAddress').value, + deposit: control.get('deposit').value, + }); + }); const payload: QuickConnectDialogResult = { token: this.form.value.token, - connectionChoices: undefined, // TODO + connectionChoices: choices, }; this.dialogRef.close(payload); } @@ -94,11 +102,13 @@ export class QuickConnectDialogComponent implements OnInit, OnDestroy { const tokenValueChange$: Observable = this.form.controls .token.valueChanges; const tokenNetworkAddress$ = tokenValueChange$.pipe( + delay(0), filter((token) => !!token), tap(() => { this.loading = true; this.resetError(); }), + delay(0), switchMap((token) => this.raidenService.getTokenNetworkAddress(token.address) ) diff --git a/src/app/components/token-input/token-input.component.ts b/src/app/components/token-input/token-input.component.ts index 399afe7c..c21838fa 100644 --- a/src/app/components/token-input/token-input.component.ts +++ b/src/app/components/token-input/token-input.component.ts @@ -75,7 +75,14 @@ export class TokenInputComponent implements ControlValueAccessor, Validator { set selectedToken(value: UserToken) { this._selectedToken = value; - this.setAmount(); + if (BigNumber.isBigNumber(this.amount) && !this.amount.isNaN()) { + const newDecimalAmount = amountToDecimal( + this.amount, + this.decimals + ).toFixed(); + this.inputElement.nativeElement.value = newDecimalAmount; + } + this.onChange(); } set maxAmount(value: BigNumber) { @@ -97,10 +104,10 @@ export class TokenInputComponent implements ControlValueAccessor, Validator { } else if (typeof obj === 'string') { this.inputElement.nativeElement.value = obj; this.onChange(); - } else if (BigNumber.isBigNumber(obj)) { + } else if (BigNumber.isBigNumber(obj) && !obj.isNaN()) { const value = amountToDecimal(obj, this.decimals).toFixed(); this.inputElement.nativeElement.value = value; - this.onChange(); + this.setAmount(); } } @@ -114,6 +121,7 @@ export class TokenInputComponent implements ControlValueAccessor, Validator { onChange() { this.setAmount(); + this.propagateChange(this.amount); } onTouched() { @@ -172,6 +180,5 @@ export class TokenInputComponent implements ControlValueAccessor, Validator { } this.amount = amount; - this.propagateChange(this.amount); } } diff --git a/src/app/models/connection.ts b/src/app/models/connection.ts index c37e474d..d044b74e 100644 --- a/src/app/models/connection.ts +++ b/src/app/models/connection.ts @@ -12,7 +12,7 @@ export interface Connections { export interface ConnectionChoice { readonly partnerAddress: string; - readonly balance: BigNumber; + readonly deposit: BigNumber; } export interface SuggestedConnection { diff --git a/src/app/modules/material-components/material-components.module.ts b/src/app/modules/material-components/material-components.module.ts index dfb2043b..41852707 100644 --- a/src/app/modules/material-components/material-components.module.ts +++ b/src/app/modules/material-components/material-components.module.ts @@ -11,6 +11,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; import { MatSidenavModule } from '@angular/material/sidenav'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatSliderModule } from '@angular/material/slider'; import { CdkTableModule } from '@angular/cdk/table'; import { OverlayModule } from '@angular/cdk/overlay'; @@ -27,6 +28,7 @@ import { OverlayModule } from '@angular/cdk/overlay'; MatProgressSpinnerModule, MatAutocompleteModule, MatSidenavModule, + MatSliderModule, CdkTableModule, OverlayModule, ], diff --git a/src/app/modules/raiden-icons/raiden-icons.module.ts b/src/app/modules/raiden-icons/raiden-icons.module.ts index 0c67e8f0..633797ad 100644 --- a/src/app/modules/raiden-icons/raiden-icons.module.ts +++ b/src/app/modules/raiden-icons/raiden-icons.module.ts @@ -37,6 +37,7 @@ export class RaidenIconsModule { 'down-arrow', 'paste', 'help', + 'graph', ]; constructor( diff --git a/src/assets/icons/graph.svg b/src/assets/icons/graph.svg new file mode 100644 index 00000000..2d057a28 --- /dev/null +++ b/src/assets/icons/graph.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/styles.scss b/src/styles.scss index 68dd78b1..00991a64 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -280,6 +280,52 @@ body { opacity: 0.5; } +.mat-slider-horizontal { + height: 32px !important; + min-width: 108px !important; + + .mat-slider-wrapper { + height: 3px !important; + top: 15px !important; + } + + .mat-slider-track-wrapper { + height: 100% !important; + } + + .mat-slider-track-background { + height: 100% !important; + background-color: $bg-light-grey !important; + } + + .mat-slider-track-fill { + height: 100% !important; + } +} + +.mat-slider-thumb { + width: 14px !important; + height: 14px !important; + right: -7px !important; + bottom: -7px !important; +} + +.mat-slider-min-value:not(.mat-slider-thumb-label-showing) .mat-slider-thumb { + border: 1px solid $dark-grey !important; + background-color: $white !important; +} + +.mat-slider-disabled .mat-slider-thumb, +.mat-slider-min-value:not(.mat-slider-thumb-label-showing).mat-slider-disabled + .mat-slider-thumb { + border: none; + background-color: $grey !important; +} + +.mat-slider-disabled .mat-slider-track-fill { + background-color: $bg-light-grey !important; +} + input:focus { outline: none; } From ca0e91e15af62174d0ac93b47193bf92f35ab02a Mon Sep 17 00:00:00 2001 From: manuelwedler Date: Fri, 13 Nov 2020 11:21:34 +0100 Subject: [PATCH 10/17] Add tests for ConnectionSelectorComponent --- .../connection-selector.component.html | 6 +- .../connection-selector.component.spec.ts | 324 +++++++++++++++++- src/testing/mock-config.ts | 2 +- src/testing/test-data.ts | 20 +- 4 files changed, 344 insertions(+), 8 deletions(-) diff --git a/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.html b/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.html index 5c8cbf29..b5ab214e 100644 --- a/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.html +++ b/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.html @@ -77,7 +77,11 @@ fxLayout="row" fxLayoutAlign="space-between center" > - + Split Equally diff --git a/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.spec.ts b/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.spec.ts index 97035e5c..30933133 100644 --- a/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.spec.ts +++ b/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.spec.ts @@ -1,24 +1,338 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { + ComponentFixture, + fakeAsync, + flush, + TestBed, + tick, +} from '@angular/core/testing'; +import { + ControlContainer, + FormArray, + FormArrayName, + FormControl, + FormGroup, + FormGroupDirective, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { MatSlider } from '@angular/material/slider'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TokenInputComponent } from 'app/components/token-input/token-input.component'; +import { MaterialComponentsModule } from 'app/modules/material-components/material-components.module'; +import { RaidenIconsModule } from 'app/modules/raiden-icons/raiden-icons.module'; +import { RaidenConfig } from 'app/services/raiden.config'; +import { amountToDecimal } from 'app/utils/amount.converter'; +import BigNumber from 'bignumber.js'; +import { ClipboardModule } from 'ngx-clipboard'; +import { clickElement, mockInput } from 'testing/interaction-helper'; +import { createSuggestedConnections, createToken } from 'testing/test-data'; +import { TestProviders } from 'testing/test-providers'; import { ConnectionSelectorComponent } from './connection-selector.component'; -fdescribe('ConnectionSelectorComponent', () => { +describe('ConnectionSelectorComponent', () => { let component: ConnectionSelectorComponent; let fixture: ComponentFixture; + let parentFormGroup: FormGroup; + const token = createToken({ + decimals: 18, + balance: new BigNumber('10000000000000000000'), + }); + const totalAmount = new BigNumber('3000000000000000000'); + const suggestions = createSuggestedConnections(); + + function initComponentForFakeAsync() { + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + tick(300); + fixture.detectChanges(); + } + beforeEach(async () => { + parentFormGroup = new FormGroup({ + token: new FormControl(token, Validators.required), + totalAmount: new FormControl(totalAmount, Validators.required), + choices: new FormArray([], Validators.required), + }); + const formGroupDirective: FormGroupDirective = new FormGroupDirective( + [], + [] + ); + formGroupDirective.form = parentFormGroup; + const formArrayName = new FormArrayName(formGroupDirective, [], []); + formArrayName.name = 'choices'; + await TestBed.configureTestingModule({ - declarations: [ConnectionSelectorComponent], + declarations: [ConnectionSelectorComponent, TokenInputComponent], + providers: [ + TestProviders.MockRaidenConfigProvider(), + TestProviders.AddressBookStubProvider(), + { provide: ControlContainer, useValue: formArrayName }, + ], + imports: [ + MaterialComponentsModule, + ReactiveFormsModule, + NoopAnimationsModule, + RaidenIconsModule, + HttpClientTestingModule, + ClipboardModule, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(ConnectionSelectorComponent); component = fixture.componentInstance; - fixture.detectChanges(); + // @ts-ignore + component.suggestions = suggestions; }); it('should create', () => { + fixture.detectChanges(); expect(component).toBeTruthy(); }); + + it('should show the suggestions', fakeAsync(() => { + initComponentForFakeAsync(); + + const addressNodes = fixture.debugElement.queryAll(By.css('.address')); + expect(addressNodes.length).toEqual(3); + addressNodes.forEach((node, index) => { + expect(node.nativeElement.innerText.trim()).toEqual( + suggestions[index].address + ); + }); + })); + + it('should set the url to the Raiden Explorer', fakeAsync(() => { + initComponentForFakeAsync(); + + const raidenConfig = TestBed.inject(RaidenConfig); + raidenConfig.network$.subscribe((network) => { + const graphLinks = fixture.debugElement.queryAll( + By.css('.graph-button') + ); + graphLinks.forEach((graphLink, index) => { + expect(graphLink.attributes['href']).toEqual( + `${network.explorerUrl}/tokens/${token.address}?node=${suggestions[index].address}` + ); + }); + }); + flush(); + })); + + it('should set the deposit of the first suggestion to total by default', fakeAsync(() => { + initComponentForFakeAsync(); + + component.choicesForm.controls.forEach((control, index) => { + const depositValue: BigNumber = control.get('deposit').value; + if (index === 0) { + expect(depositValue.isEqualTo(totalAmount)).toBe(true); + } else { + expect(depositValue.isEqualTo(0)).toBe(true); + } + }); + })); + + it('should update the other fields accordingly for new deposit value inputs', fakeAsync(() => { + initComponentForFakeAsync(); + + const tokenInputs = fixture.debugElement.queryAll( + By.directive(TokenInputComponent) + ); + const firstValue = new BigNumber('2000000000000000000'); + mockInput( + tokenInputs[1], + 'input', + amountToDecimal(firstValue, token.decimals).toString() + ); + tick(300); + fixture.detectChanges(); + + component.choicesForm.controls.forEach((control, index) => { + const depositValue: BigNumber = control.get('deposit').value; + if (index === 0) { + expect( + depositValue.isEqualTo(totalAmount.minus(firstValue)) + ).toBe(true); + } else if (index === 1) { + expect(depositValue.isEqualTo(firstValue)).toBe(true); + } else { + expect(depositValue.isEqualTo(0)).toBe(true); + } + }); + + const secondValue = new BigNumber('1000000000000000001'); + const changeOtherControls = new BigNumber('500000000000000000'); + const remainder = new BigNumber('1'); + mockInput( + tokenInputs[2], + 'input', + amountToDecimal(secondValue, token.decimals).toString() + ); + tick(300); + fixture.detectChanges(); + + component.choicesForm.controls.forEach((control, index) => { + const depositValue: BigNumber = control.get('deposit').value; + if (index === 0) { + expect( + depositValue.isEqualTo( + totalAmount + .minus(firstValue) + .minus(changeOtherControls) + .minus(remainder) + ) + ).toBe(true); + } else if (index === 1) { + expect( + depositValue.isEqualTo( + firstValue.minus(changeOtherControls) + ) + ).toBe(true); + } else { + expect(depositValue.isEqualTo(secondValue)).toBe(true); + } + }); + })); + + it('should update the other fields accordingly when the slider is used', fakeAsync(() => { + initComponentForFakeAsync(); + + const tokenInputs = fixture.debugElement.queryAll( + By.directive(TokenInputComponent) + ); + const value = new BigNumber('1000000000000000000'); + mockInput( + tokenInputs[1], + 'input', + amountToDecimal(value, token.decimals).toString() + ); + tick(300); + fixture.detectChanges(); + + const sliders = fixture.debugElement.queryAll(By.directive(MatSlider)); + (sliders[2].componentInstance).input.emit({ + source: sliders[2].componentInstance, + value: 1, + }); + tick(300); + fixture.detectChanges(); + + component.choicesForm.controls.forEach((control, index) => { + const depositValue: BigNumber = control.get('deposit').value; + if (index === 2) { + expect(depositValue.isEqualTo(totalAmount)).toBe(true); + } else { + expect(depositValue.isEqualTo(0)).toBe(true); + } + }); + })); + + it('should split the total amount equally when button is clicked', fakeAsync(() => { + initComponentForFakeAsync(); + + clickElement(fixture.debugElement, '#split-equally'); + + const valuePerControl = totalAmount.dividedBy(3).integerValue(); + component.choicesForm.controls.forEach((control, index) => { + const depositValue: BigNumber = control.get('deposit').value; + expect(depositValue.isEqualTo(valuePerControl)).toBe(true); + }); + })); + + it('should split the total amount equally among two controls if only two have a value', fakeAsync(() => { + initComponentForFakeAsync(); + + const tokenInputs = fixture.debugElement.queryAll( + By.directive(TokenInputComponent) + ); + const value = new BigNumber('1000000000000000000'); + mockInput( + tokenInputs[1], + 'input', + amountToDecimal(value, token.decimals).toString() + ); + tick(300); + fixture.detectChanges(); + + clickElement(fixture.debugElement, '#split-equally'); + + const valuePerControl = totalAmount.dividedBy(2).integerValue(); + component.choicesForm.controls.forEach((control, index) => { + const depositValue: BigNumber = control.get('deposit').value; + if (index === 2) { + expect(depositValue.isEqualTo(0)).toBe(true); + } else { + expect(depositValue.isEqualTo(valuePerControl)).toBe(true); + } + }); + })); + + it('should disable the control if total amount is invalid', fakeAsync(() => { + initComponentForFakeAsync(); + + parentFormGroup.get('totalAmount').setValue('100invalid'); + parentFormGroup.get('totalAmount').setErrors({ notANumber: true }); + tick(); + + expect(component.choicesForm.disabled).toBe(true); + })); + + it('should reset the first deposit to total amount when total amount changes', fakeAsync(() => { + initComponentForFakeAsync(); + + const newTotal = new BigNumber('9999999999'); + parentFormGroup.get('totalAmount').setValue(newTotal); + tick(); + + component.choicesForm.controls.forEach((control, index) => { + const depositValue: BigNumber = control.get('deposit').value; + if (index === 0) { + expect(depositValue.isEqualTo(newTotal)).toBe(true); + } else { + expect(depositValue.isEqualTo(0)).toBe(true); + } + }); + })); + + it('should show an error if one deposit has more than total amount', fakeAsync(() => { + initComponentForFakeAsync(); + + const tokenInputs = fixture.debugElement.queryAll( + By.directive(TokenInputComponent) + ); + const value = totalAmount.plus(1); + mockInput( + tokenInputs[0], + 'input', + amountToDecimal(value, token.decimals).toString() + ); + tick(300); + fixture.detectChanges(); + + expect(component.choicesForm.errors).toEqual({ + insufficientFunds: true, + }); + + fixture.destroy(); + })); + + it('should show an error if all deposits are zero', fakeAsync(() => { + initComponentForFakeAsync(); + + const tokenInputs = fixture.debugElement.queryAll( + By.directive(TokenInputComponent) + ); + mockInput(tokenInputs[0], 'input', '0'); + tick(300); + fixture.detectChanges(); + + expect(component.choicesForm.errors).toEqual({ noSelection: true }); + + fixture.destroy(); + })); }); diff --git a/src/testing/mock-config.ts b/src/testing/mock-config.ts index afe08e63..ec474eff 100644 --- a/src/testing/mock-config.ts +++ b/src/testing/mock-config.ts @@ -50,7 +50,7 @@ const mockNetwork = createNetworkMock(); @Injectable() export class MockConfig extends RaidenConfig { web3: Web3 = mockProvider.web3; - api: string = 'localhost:5001/api/v1'; + api = 'localhost:5001/api/v1'; constructor() { super(stub(), stub(), mockProvider); diff --git a/src/testing/test-data.ts b/src/testing/test-data.ts index 4c1caf37..d4ff5c41 100644 --- a/src/testing/test-data.ts +++ b/src/testing/test-data.ts @@ -7,7 +7,7 @@ import { UserToken } from '../app/models/usertoken'; import { PaymentEvent } from '../app/models/payment-event'; import { PendingTransfer } from '../app/models/pending-transfer'; import { ContractsInfo } from '../app/models/contracts-info'; -import { Connection } from '../app/models/connection'; +import { Connection, SuggestedConnection } from '../app/models/connection'; const web3 = new Web3('http://localhost:8545'); @@ -100,6 +100,7 @@ export function createNetworkMock(obj: any = {}): Network { shortName: 'tst', chainId: 9001, ensSupported: true, + explorerUrl: 'https://testing.explorer.raiden.network', faucet: 'http://faucet.test/?${ADDRESS}', }; return Object.assign(network, obj); @@ -186,3 +187,20 @@ export function createContractsInfo(obj: any = {}): ContractsInfo { }; return Object.assign(contracts, obj); } + +export function createSuggestedConnections( + count: number = 5 +): SuggestedConnection[] { + const suggestions: SuggestedConnection[] = []; + for (let i = 0; i < count; i++) { + suggestions.push({ + address: createAddress(), + score: BigNumber.random(19).times(10 ** 19), + centrality: BigNumber.random(21), + uptime: BigNumber.random(5).times(10 ** 5), + capacity: BigNumber.random(19).times(10 ** 19), + }); + } + suggestions.sort((a, b) => a.score.minus(b.score).toNumber()); + return suggestions; +} From 2bcd89d6d25aac733de195cc6bebac9fe8e39256 Mon Sep 17 00:00:00 2001 From: manuelwedler Date: Wed, 18 Nov 2020 17:20:02 +0100 Subject: [PATCH 11/17] Add tests for QuickConnectDialogComponent --- .../connection-selector.component.spec.ts | 10 +- .../quick-connect-dialog.component.spec.ts | 318 +++++++++++++++++- .../quick-connect-dialog.component.ts | 13 +- src/app/utils/lossless-json.converter.spec.ts | 6 + src/app/utils/lossless-json.converter.ts | 2 +- src/testing/test-data.ts | 8 + 6 files changed, 336 insertions(+), 21 deletions(-) diff --git a/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.spec.ts b/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.spec.ts index 30933133..e3650a5c 100644 --- a/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.spec.ts +++ b/src/app/components/quick-connect-dialog/connection-selector/connection-selector.component.spec.ts @@ -2,7 +2,6 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ComponentFixture, fakeAsync, - flush, TestBed, tick, } from '@angular/core/testing'; @@ -121,7 +120,6 @@ describe('ConnectionSelectorComponent', () => { ); }); }); - flush(); })); it('should set the deposit of the first suggestion to total by default', fakeAsync(() => { @@ -311,14 +309,14 @@ describe('ConnectionSelectorComponent', () => { 'input', amountToDecimal(value, token.decimals).toString() ); + tick(); + fixture.detectChanges(); tick(300); fixture.detectChanges(); expect(component.choicesForm.errors).toEqual({ insufficientFunds: true, }); - - fixture.destroy(); })); it('should show an error if all deposits are zero', fakeAsync(() => { @@ -328,11 +326,11 @@ describe('ConnectionSelectorComponent', () => { By.directive(TokenInputComponent) ); mockInput(tokenInputs[0], 'input', '0'); + tick(); + fixture.detectChanges(); tick(300); fixture.detectChanges(); expect(component.choicesForm.errors).toEqual({ noSelection: true }); - - fixture.destroy(); })); }); diff --git a/src/app/components/quick-connect-dialog/quick-connect-dialog.component.spec.ts b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.spec.ts index 67fcd663..c4da009b 100644 --- a/src/app/components/quick-connect-dialog/quick-connect-dialog.component.spec.ts +++ b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.spec.ts @@ -1,24 +1,322 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { QuickConnectDialogComponent } from './quick-connect-dialog.component'; +import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatSelect } from '@angular/material/select'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { LosslessJsonInterceptor } from 'app/interceptors/lossless-json.interceptor'; +import { Channel } from 'app/models/channel'; +import { SuggestedConnection } from 'app/models/connection'; +import { MaterialComponentsModule } from 'app/modules/material-components/material-components.module'; +import { RaidenIconsModule } from 'app/modules/raiden-icons/raiden-icons.module'; +import { DecimalPipe } from 'app/pipes/decimal.pipe'; +import { DisplayDecimalsPipe } from 'app/pipes/display-decimals.pipe'; +import { ChannelPollingService } from 'app/services/channel-polling.service'; +import { RaidenService } from 'app/services/raiden.service'; +import { TokenPollingService } from 'app/services/token-polling.service'; +import { amountFromDecimal } from 'app/utils/amount.converter'; +import { losslessStringify } from 'app/utils/lossless-json.converter'; +import BigNumber from 'bignumber.js'; +import { ClipboardModule } from 'ngx-clipboard'; +import { BehaviorSubject, of } from 'rxjs'; +import { + clickElement, + mockInput, + mockMatSelectFirst, + mockOpenMatSelect, +} from 'testing/interaction-helper'; +import { stub } from 'testing/stub'; +import { + createAddress, + createChannel, + createSettings, + createSuggestedConnections, + createToken, +} from 'testing/test-data'; +import { TestProviders } from 'testing/test-providers'; +import { BalanceWithSymbolComponent } from '../balance-with-symbol/balance-with-symbol.component'; +import { RaidenDialogComponent } from '../raiden-dialog/raiden-dialog.component'; +import { TokenInputComponent } from '../token-input/token-input.component'; +import { TokenNetworkSelectorComponent } from '../token-network-selector/token-network-selector.component'; +import { ConnectionSelectorComponent } from './connection-selector/connection-selector.component'; +import { + QuickConnectDialogComponent, + QuickConnectDialogPayload, +} from './quick-connect-dialog.component'; describe('QuickConnectDialogComponent', () => { let component: QuickConnectDialogComponent; let fixture: ComponentFixture; + const suggestions = createSuggestedConnections(); + const token = createToken({ + decimals: 0, + balance: new BigNumber(1000), + connected: { + channels: 5, + funds: new BigNumber(10), + sum_deposits: new BigNumber(50), + }, + }); + const tokenNetworkAddress = createAddress(); + const settings = createSettings(); + const totalAmountInput = '70'; + const totalAmountValue = amountFromDecimal( + new BigNumber(totalAmountInput), + token.decimals + ); + let channelsSubject: BehaviorSubject; + let mockHttp: HttpTestingController; + + function initServices() { + const raidenService = TestBed.inject(RaidenService); + spyOn(raidenService, 'getTokenNetworkAddress').and.returnValue( + of(tokenNetworkAddress) + ); + spyOn(raidenService, 'getSettings').and.returnValue(of(settings)); + mockHttp = TestBed.inject(HttpTestingController); + } + beforeEach(async () => { + const payload: QuickConnectDialogPayload = { + token: token, + }; + + const tokenPollingMock = stub(); + // @ts-ignore + tokenPollingMock.tokens$ = of([token]); + tokenPollingMock.getTokenUpdates = (tokenAddress) => + of(tokenAddress ? token : undefined); + + const channelPollingMock = stub(); + channelsSubject = new BehaviorSubject([]); + // @ts-ignore + channelPollingMock.channels$ = channelsSubject.asObservable(); + await TestBed.configureTestingModule({ - declarations: [QuickConnectDialogComponent], + declarations: [ + QuickConnectDialogComponent, + TokenInputComponent, + RaidenDialogComponent, + DecimalPipe, + DisplayDecimalsPipe, + TokenNetworkSelectorComponent, + BalanceWithSymbolComponent, + ConnectionSelectorComponent, + ], + providers: [ + TestProviders.MockMatDialogData(payload), + TestProviders.MockMatDialogRef({ close: () => {} }), + TestProviders.MockRaidenConfigProvider(), + { + provide: TokenPollingService, + useValue: tokenPollingMock, + }, + TestProviders.AddressBookStubProvider(), + { + provide: ChannelPollingService, + useValue: channelPollingMock, + }, + { + provide: HTTP_INTERCEPTORS, + useClass: LosslessJsonInterceptor, + multi: true, + }, + ], + imports: [ + MaterialComponentsModule, + ReactiveFormsModule, + NoopAnimationsModule, + RaidenIconsModule, + HttpClientTestingModule, + ClipboardModule, + ], }).compileComponents(); }); - beforeEach(() => { - fixture = TestBed.createComponent(QuickConnectDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + describe('with token payload', () => { + function initComponentForFakeAsync( + suggestionsResponse: SuggestedConnection[] | Error = suggestions + ) { + fixture.detectChanges(); + tick(); + + const request = mockHttp.expectOne({ + url: `${settings.pathfinding_service_address}/api/v1/${tokenNetworkAddress}/suggest_partner`, + method: 'GET', + }); + request.flush(losslessStringify(suggestionsResponse), { + status: suggestionsResponse instanceof Error ? 400 : 200, + statusText: '', + }); + + tick(300); + fixture.detectChanges(); + tick(); + } + + beforeEach(() => { + initServices(); + fixture = TestBed.createComponent(QuickConnectDialogComponent); + component = fixture.componentInstance; + }); + + it('should be created', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should close the dialog with the result when accept button is clicked', fakeAsync(() => { + initComponentForFakeAsync(); + + mockInput( + fixture.debugElement.query( + By.css('app-token-input[formControlName="totalAmount"') + ), + 'input', + totalAmountInput + ); + fixture.detectChanges(); + + // @ts-ignore + const closeSpy = spyOn(component.dialogRef, 'close'); + clickElement(fixture.debugElement, '#accept'); + tick(300); + fixture.detectChanges(); + + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledWith({ + token: token, + connectionChoices: [ + { + partnerAddress: suggestions[0].address, + deposit: totalAmountValue, + }, + ], + }); + })); + + it('should close the dialog with no result when cancel button is clicked', fakeAsync(() => { + initComponentForFakeAsync(); + + mockInput( + fixture.debugElement.query( + By.css('app-token-input[formControlName="totalAmount"') + ), + 'input', + totalAmountInput + ); + fixture.detectChanges(); + + // @ts-ignore + const closeSpy = spyOn(component.dialogRef, 'close'); + clickElement(fixture.debugElement, '#cancel'); + tick(300); + fixture.detectChanges(); + + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledWith(); + })); + + it('should set the maximum token amount to the balance', fakeAsync(() => { + initComponentForFakeAsync(); + + const tokenInputComponent: TokenInputComponent = fixture.debugElement.query( + By.css('app-token-input[formControlName="totalAmount"') + ).componentInstance; + expect(tokenInputComponent.maxAmount.isEqualTo(token.balance)).toBe( + true + ); + })); + + it('should filter the suggestions by existing channels', fakeAsync(() => { + channelsSubject.next([ + createChannel({ + userToken: token, + partner_address: suggestions[0].address, + }), + ]); + initComponentForFakeAsync(); + + expect(component.suggestions.length).toEqual( + suggestions.length - 1 + ); + expect(component.suggestions).toEqual(suggestions.slice(1)); + })); + + it('should show an error if there are no suggestions', fakeAsync(() => { + initComponentForFakeAsync([]); + + expect(component.suggestions.length).toEqual(0); + expect(component.pfsError).toBe(true); + expect(component.form.controls.totalAmount.disabled).toBe(true); + })); + + it('should show an error if there it fails to request the suggestions', fakeAsync(() => { + initComponentForFakeAsync(new Error('API not supported')); + + expect(component.suggestions.length).toEqual(0); + expect(component.pfsError).toBe(true); + expect(component.form.controls.totalAmount.disabled).toBe(true); + })); }); - it('should create', () => { - expect(component).toBeTruthy(); + describe('without token payload', () => { + beforeEach(() => { + const payload: QuickConnectDialogPayload = { + token: undefined, + }; + TestBed.overrideProvider(MAT_DIALOG_DATA, { useValue: payload }); + initServices(); + fixture = TestBed.createComponent(QuickConnectDialogComponent); + component = fixture.componentInstance; + }); + + it('should show a token network selector', () => { + fixture.detectChanges(); + const selector = fixture.debugElement.query( + By.directive(MatSelect) + ); + expect(selector).toBeTruthy(); + expect(component.form.value.token).toBeFalsy(); + }); + + it('should not set the maximum token amount by default', () => { + fixture.detectChanges(); + + const tokenInputComponent: TokenInputComponent = fixture.debugElement.query( + By.css('app-token-input[formControlName="totalAmount"') + ).componentInstance; + expect(tokenInputComponent.maxAmount).toBeUndefined(); + }); + + it('should set the maximum token amount after token selection', () => { + fixture.detectChanges(); + + const networkSelectorElement = fixture.debugElement.query( + By.directive(TokenNetworkSelectorComponent) + ); + mockOpenMatSelect(networkSelectorElement); + fixture.detectChanges(); + mockMatSelectFirst(fixture.debugElement); + fixture.detectChanges(); + + const tokenInputComponent: TokenInputComponent = fixture.debugElement.query( + By.css('app-token-input[formControlName="totalAmount"') + ).componentInstance; + expect(tokenInputComponent.maxAmount.isEqualTo(token.balance)).toBe( + true + ); + }); }); }); diff --git a/src/app/components/quick-connect-dialog/quick-connect-dialog.component.ts b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.ts index 19746170..6147b2d3 100644 --- a/src/app/components/quick-connect-dialog/quick-connect-dialog.component.ts +++ b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.ts @@ -8,6 +8,7 @@ import { UserToken } from 'app/models/usertoken'; import { ChannelPollingService } from 'app/services/channel-polling.service'; import { RaidenService } from 'app/services/raiden.service'; import { TokenPollingService } from 'app/services/token-polling.service'; +import BigNumber from 'bignumber.js'; import { combineLatest, Observable, Subject, zip } from 'rxjs'; import { catchError, @@ -79,11 +80,15 @@ export class QuickConnectDialogComponent implements OnInit, OnDestroy { accept() { const choices: ConnectionChoice[] = []; (this.form.get('choices')).controls.forEach((control) => { - choices.push({ - partnerAddress: control.get('partnerAddress').value, - deposit: control.get('deposit').value, - }); + const depositValue: BigNumber = control.get('deposit').value; + if (depositValue.isGreaterThan(0)) { + choices.push({ + partnerAddress: control.get('partnerAddress').value, + deposit: depositValue, + }); + } }); + const payload: QuickConnectDialogResult = { token: this.form.value.token, connectionChoices: choices, diff --git a/src/app/utils/lossless-json.converter.spec.ts b/src/app/utils/lossless-json.converter.spec.ts index d17774bd..20dda92f 100644 --- a/src/app/utils/lossless-json.converter.spec.ts +++ b/src/app/utils/lossless-json.converter.spec.ts @@ -38,4 +38,10 @@ describe('LosslessJsonConverter', () => { }); expect(stringified).toBe('{"number":"100","text":"Hello"}'); }); + + it('should parse decimals to BigNumbers', () => { + const parsed = losslessParse('{"big":"21.201"}'); + expect(BigNumber.isBigNumber(parsed.big)).toBe(true); + expect(parsed.big).toEqual(new BigNumber('21.201')); + }); }); diff --git a/src/app/utils/lossless-json.converter.ts b/src/app/utils/lossless-json.converter.ts index 063c33ee..d40eff7f 100644 --- a/src/app/utils/lossless-json.converter.ts +++ b/src/app/utils/lossless-json.converter.ts @@ -1,7 +1,7 @@ import BigNumber from 'bignumber.js'; export function losslessParse(json: string): any { - const numberRegex = /^\d+$/; + const numberRegex = /^\d+(\.\d+)?$/; return JSON.parse(json, (key, value) => (typeof value === 'string' && numberRegex.test(value)) || typeof value === 'number' diff --git a/src/testing/test-data.ts b/src/testing/test-data.ts index d4ff5c41..fb688d05 100644 --- a/src/testing/test-data.ts +++ b/src/testing/test-data.ts @@ -8,6 +8,7 @@ import { PaymentEvent } from '../app/models/payment-event'; import { PendingTransfer } from '../app/models/pending-transfer'; import { ContractsInfo } from '../app/models/contracts-info'; import { Connection, SuggestedConnection } from '../app/models/connection'; +import { Settings } from 'app/models/settings'; const web3 = new Web3('http://localhost:8545'); @@ -204,3 +205,10 @@ export function createSuggestedConnections( suggestions.sort((a, b) => a.score.minus(b.score).toNumber()); return suggestions; } + +export function createSettings(): Settings { + return { + pathfinding_service_address: + 'https://testing-pfs.transport01.raiden.network', + }; +} From 78797943f89fff29e73904490199b1e0a6e5fcbc Mon Sep 17 00:00:00 2001 From: manuelwedler Date: Wed, 18 Nov 2020 18:57:18 +0100 Subject: [PATCH 12/17] Add method for opening a batch of channels to RaidenService --- src/app/services/raiden.service.spec.ts | 120 +++++++++++++++++++++++- src/app/services/raiden.service.ts | 73 +++++++++++++- src/testing/test-data.ts | 17 +++- 3 files changed, 206 insertions(+), 4 deletions(-) diff --git a/src/app/services/raiden.service.spec.ts b/src/app/services/raiden.service.spec.ts index 4ac02652..352e9575 100644 --- a/src/app/services/raiden.service.spec.ts +++ b/src/app/services/raiden.service.spec.ts @@ -23,6 +23,7 @@ import { createNetworkMock, createAddress, createContractsInfo, + createConnectionChoices, } from '../../testing/test-data'; import Spy = jasmine.Spy; import { Connection } from '../models/connection'; @@ -218,7 +219,7 @@ describe('RaidenService', () => { }); it('should request the api to open a channel', () => { - const partnerAddress = '0xc52952ebad56f2c5e5b42bb881481ae27d036475'; + const partnerAddress = createAddress(); service .openChannel(token.address, partnerAddress, 500, new BigNumber(10)) @@ -249,7 +250,7 @@ describe('RaidenService', () => { }); it('Show a proper response when non-EIP addresses are passed in channel creation', () => { - const partnerAddress = '0xc52952ebad56f2c5e5b42bb881481ae27d036475'; + const partnerAddress = createAddress(); service .openChannel(token.address, partnerAddress, 500, new BigNumber(10)) @@ -1598,4 +1599,119 @@ describe('RaidenService', () => { statusText: '', }); }); + + it('should open a batch of channels', fakeAsync(() => { + const raidenConfig = TestBed.inject(RaidenConfig); + const choices = createConnectionChoices(); + + service + .openBatchOfChannels(token, choices) + .subscribe((value) => { + expect(value).toBeFalsy(); + expect( + notificationService.addSuccessNotification + ).toHaveBeenCalledTimes(1); + }) + .add(() => { + expect( + notificationService.removePendingAction + ).toHaveBeenCalledTimes(4); + }); + tick(); + + expect(notificationService.addPendingAction).toHaveBeenCalledTimes( + choices.length + 1 + ); + + choices.forEach((choice) => { + const requests = mockHttp.match((request) => { + const body = losslessParse(request.body); + return ( + request.url === `${endpoint}/channels` && + request.method === 'PUT' && + body.partner_address === choice.partnerAddress && + body.total_deposit.isEqualTo(choice.deposit) && + body.settle_timeout.isEqualTo( + raidenConfig.config.settle_timeout + ) + ); + }); + expect(requests.length).toBe(1); + + requests[0].flush( + losslessStringify( + createChannel({ + partner_address: choice.partnerAddress, + balance: choice.deposit, + total_deposit: choice.deposit, + total_withdraw: new BigNumber(0), + token_address: token.address, + }) + ), + { + status: 200, + statusText: '', + } + ); + }); + flush(); + })); + + it('should not cancel other channel openings if one fails when opening in a batch', fakeAsync(() => { + const raidenConfig = TestBed.inject(RaidenConfig); + const choices = createConnectionChoices(); + + service.openBatchOfChannels(token, choices).subscribe((value) => { + expect(value).toBeFalsy(); + expect( + notificationService.addSuccessNotification + ).toHaveBeenCalledTimes(1); + }); + tick(); + + choices.forEach((choice, index) => { + const requests = mockHttp.match((request) => { + const body = losslessParse(request.body); + return ( + request.url === `${endpoint}/channels` && + request.method === 'PUT' && + body.partner_address === choice.partnerAddress && + body.total_deposit.isEqualTo(choice.deposit) && + body.settle_timeout.isEqualTo( + raidenConfig.config.settle_timeout + ) + ); + }); + expect(requests.length).toBe(1); + + if (index === 0) { + requests[0].flush( + { + errors: 'Channel already exists', + }, + { + status: 409, + statusText: '', + } + ); + } else { + requests[0].flush( + losslessStringify( + createChannel({ + partner_address: choice.partnerAddress, + balance: choice.deposit, + total_deposit: choice.deposit, + total_withdraw: new BigNumber(0), + token_address: token.address, + }) + ), + { + status: 200, + statusText: '', + } + ); + } + }); + flush(); + })); }); diff --git a/src/app/services/raiden.service.ts b/src/app/services/raiden.service.ts index 7c359734..6aaeb4dd 100644 --- a/src/app/services/raiden.service.ts +++ b/src/app/services/raiden.service.ts @@ -14,6 +14,7 @@ import { BehaviorSubject, Subject, throwError, + forkJoin, } from 'rxjs'; import { fromPromise } from 'rxjs/internal-compatibility'; import { @@ -33,7 +34,7 @@ import { switchMapTo, } from 'rxjs/operators'; import { Channel } from '../models/channel'; -import { Connections } from '../models/connection'; +import { ConnectionChoice, Connections } from '../models/connection'; import { PaymentEvent } from '../models/payment-event'; import { UserToken } from '../models/usertoken'; import { amountToDecimal } from '../utils/amount.converter'; @@ -646,6 +647,76 @@ export class RaidenService { ); } + openBatchOfChannels( + token: UserToken, + connectionChoices: ConnectionChoice[] + ): Observable { + let notificationIdentifier: number; + let errorCount = 0; + + return of(null).pipe( + tap(() => { + this.quickConnectPending[token.address] = true; + const message: UiMessage = { + title: 'Quick connect', + description: `${connectionChoices.length} channels on ${token.symbol}`, + icon: 'thunderbolt', + userToken: token, + }; + notificationIdentifier = this.notificationService.addPendingAction( + message + ); + }), + switchMap(() => { + const openChannelObservables = connectionChoices.map((choice) => + this.openChannel( + token.address, + choice.partnerAddress, + this.raidenConfig.config.settle_timeout, + choice.deposit + ).pipe( + catchError(() => { + errorCount++; + return of(null); + }) + ) + ); + return forkJoin(openChannelObservables); + }), + switchMap(() => + errorCount === connectionChoices.length + ? throwError('All channel creations failed') + : of(null) + ), + tap(() => { + const message: UiMessage = { + title: 'Quick connect successful', + description: `${ + connectionChoices.length - errorCount + } channels on ${token.symbol}`, + icon: 'thunderbolt', + userToken: token, + }; + this.notificationService.addSuccessNotification(message); + }), + catchError((error) => { + this.notificationService.addErrorNotification({ + title: 'Quick connect failed', + description: error, + icon: 'error-mark', + userToken: token, + }); + return throwError(error); + }), + finalize(() => { + this.quickConnectPending[token.address] = false; + this.notificationService.removePendingAction( + notificationIdentifier + ); + }) + ); + } + public connectTokenNetwork( funds: BigNumber, tokenAddress: string diff --git a/src/testing/test-data.ts b/src/testing/test-data.ts index fb688d05..c07c044c 100644 --- a/src/testing/test-data.ts +++ b/src/testing/test-data.ts @@ -7,7 +7,11 @@ import { UserToken } from '../app/models/usertoken'; import { PaymentEvent } from '../app/models/payment-event'; import { PendingTransfer } from '../app/models/pending-transfer'; import { ContractsInfo } from '../app/models/contracts-info'; -import { Connection, SuggestedConnection } from '../app/models/connection'; +import { + Connection, + ConnectionChoice, + SuggestedConnection, +} from '../app/models/connection'; import { Settings } from 'app/models/settings'; const web3 = new Web3('http://localhost:8545'); @@ -206,6 +210,17 @@ export function createSuggestedConnections( return suggestions; } +export function createConnectionChoices(count: number = 3): ConnectionChoice[] { + const choices: ConnectionChoice[] = []; + for (let i = 0; i < count; i++) { + choices.push({ + partnerAddress: createAddress(), + deposit: BigNumber.random(3).times(1000), + }); + } + return choices; +} + export function createSettings(): Settings { return { pathfinding_service_address: From 2dcb27379961901e9d5d75307066784bb92cbab5 Mon Sep 17 00:00:00 2001 From: manuelwedler Date: Thu, 19 Nov 2020 08:39:09 +0100 Subject: [PATCH 13/17] Open QuickConnectDialog from TokenComponent --- .../components/token/token.component.spec.ts | 54 ++++++++++--------- src/app/components/token/token.component.ts | 27 +++++----- 2 files changed, 42 insertions(+), 39 deletions(-) diff --git a/src/app/components/token/token.component.spec.ts b/src/app/components/token/token.component.spec.ts index 04c85cce..15cf9422 100644 --- a/src/app/components/token/token.component.spec.ts +++ b/src/app/components/token/token.component.spec.ts @@ -14,6 +14,7 @@ import { createAddress, createTestChannels, createTestTokens, + createConnectionChoices, } from '../../../testing/test-data'; import { By } from '@angular/platform-browser'; import { @@ -29,10 +30,6 @@ import { } from '../payment-dialog/payment-dialog.component'; import BigNumber from 'bignumber.js'; import { of, BehaviorSubject } from 'rxjs'; -import { - ConnectionManagerDialogPayload, - ConnectionManagerDialogComponent, -} from '../connection-manager-dialog/connection-manager-dialog.component'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ConfirmationDialogPayload, @@ -45,6 +42,11 @@ import { ChannelPollingService } from '../../services/channel-polling.service'; import { stub } from '../../../testing/stub'; import { BalanceWithSymbolComponent } from '../balance-with-symbol/balance-with-symbol.component'; import { TokenNetworkSelectorComponent } from '../token-network-selector/token-network-selector.component'; +import { + QuickConnectDialogComponent, + QuickConnectDialogPayload, + QuickConnectDialogResult, +} from '../quick-connect-dialog/quick-connect-dialog.component'; describe('TokenComponent', () => { let component: TokenComponent; @@ -184,12 +186,12 @@ describe('TokenComponent', () => { fixture.detectChanges(); const dialogSpy = spyOn(dialog, 'open').and.callThrough(); - const dialogResult: ConnectionManagerDialogPayload = { + const dialogResult: QuickConnectDialogResult = { token: unconnectedToken, - funds: new BigNumber(10), + connectionChoices: createConnectionChoices(), }; spyOn(dialog, 'returns').and.returnValues(true, dialogResult); - spyOn(raidenService, 'connectTokenNetwork').and.returnValue( + spyOn(raidenService, 'openBatchOfChannels').and.returnValue( of(null) ); @@ -201,9 +203,8 @@ describe('TokenComponent', () => { message: 'Do you want to use quick connect to automatically open channels?', }; - const connectionManagerPayload: ConnectionManagerDialogPayload = { + const quickConnectPayload: QuickConnectDialogPayload = { token: undefined, - funds: undefined, }; expect(dialogSpy).toHaveBeenCalledTimes(2); expect(dialogSpy.calls.first().args).toEqual([ @@ -213,9 +214,10 @@ describe('TokenComponent', () => { }, ]); expect(dialogSpy.calls.mostRecent().args).toEqual([ - ConnectionManagerDialogComponent, + QuickConnectDialogComponent, { - data: connectionManagerPayload, + data: quickConnectPayload, + width: '400px', }, ]); }); @@ -306,14 +308,14 @@ describe('TokenComponent', () => { fixture.detectChanges(); const dialogSpy = spyOn(dialog, 'open').and.callThrough(); - const dialogResult: ConnectionManagerDialogPayload = { + const dialogResult: QuickConnectDialogResult = { token: unconnectedToken, - funds: new BigNumber(10), + connectionChoices: createConnectionChoices(), }; spyOn(dialog, 'returns').and.returnValues(true, dialogResult); - const connectSpy = spyOn( + const openBatchSpy = spyOn( raidenService, - 'connectTokenNetwork' + 'openBatchOfChannels' ).and.returnValue(of(null)); clickElement(fixture.debugElement, '#transfer'); @@ -323,9 +325,8 @@ describe('TokenComponent', () => { title: `No open ${unconnectedToken.symbol} channels`, message: `Do you want to use quick connect to automatically open ${unconnectedToken.symbol} channels?`, }; - const connectionManagerPayload: ConnectionManagerDialogPayload = { + const quickConnectPayload: QuickConnectDialogPayload = { token: unconnectedToken, - funds: undefined, }; expect(dialogSpy).toHaveBeenCalledTimes(2); expect(dialogSpy.calls.first().args).toEqual([ @@ -335,15 +336,16 @@ describe('TokenComponent', () => { }, ]); expect(dialogSpy.calls.mostRecent().args).toEqual([ - ConnectionManagerDialogComponent, + QuickConnectDialogComponent, { - data: connectionManagerPayload, + data: quickConnectPayload, + width: '400px', }, ]); - expect(connectSpy).toHaveBeenCalledTimes(1); - expect(connectSpy).toHaveBeenCalledWith( - dialogResult.funds, - dialogResult.token.address + expect(openBatchSpy).toHaveBeenCalledTimes(1); + expect(openBatchSpy).toHaveBeenCalledWith( + dialogResult.token, + dialogResult.connectionChoices ); }); @@ -366,16 +368,16 @@ describe('TokenComponent', () => { const dialogSpy = spyOn(dialog, 'open').and.callThrough(); spyOn(dialog, 'returns').and.returnValues(true, null); - const connectSpy = spyOn( + const openBatchSpy = spyOn( raidenService, - 'connectTokenNetwork' + 'openBatchOfChannels' ).and.returnValue(of(null)); clickElement(fixture.debugElement, '#transfer'); fixture.detectChanges(); expect(dialogSpy).toHaveBeenCalledTimes(2); - expect(connectSpy).toHaveBeenCalledTimes(0); + expect(openBatchSpy).toHaveBeenCalledTimes(0); }); it('should mint 0.5 tokens when token has 18 decimals', () => { diff --git a/src/app/components/token/token.component.ts b/src/app/components/token/token.component.ts index 53697ba7..7bcce147 100644 --- a/src/app/components/token/token.component.ts +++ b/src/app/components/token/token.component.ts @@ -15,14 +15,15 @@ import { PaymentDialogPayload, PaymentDialogComponent, } from '../payment-dialog/payment-dialog.component'; -import { - ConnectionManagerDialogPayload, - ConnectionManagerDialogComponent, -} from '../connection-manager-dialog/connection-manager-dialog.component'; import { PendingTransferPollingService } from '../../services/pending-transfer-polling.service'; import { ChannelPollingService } from '../../services/channel-polling.service'; import { SelectedTokenService } from '../../services/selected-token.service'; import { PaymentHistoryPollingService } from '../../services/payment-history-polling.service'; +import { + QuickConnectDialogComponent, + QuickConnectDialogPayload, + QuickConnectDialogResult, +} from '../quick-connect-dialog/quick-connect-dialog.component'; @Component({ selector: 'app-token', @@ -203,33 +204,33 @@ export class TokenComponent implements OnInit, OnDestroy { dialog.afterClosed().subscribe((result) => { if (result) { - this.openConnectionManager(); + this.openQuickConnect(); } }); } - private openConnectionManager() { - const payload: ConnectionManagerDialogPayload = { + private openQuickConnect() { + const payload: QuickConnectDialogPayload = { token: this.selectedToken, - funds: undefined, }; - const dialog = this.dialog.open(ConnectionManagerDialogComponent, { + const dialog = this.dialog.open(QuickConnectDialogComponent, { data: payload, + width: '400px', }); dialog .afterClosed() .pipe( - mergeMap((result: ConnectionManagerDialogPayload) => { + mergeMap((result: QuickConnectDialogResult) => { if (!result) { return EMPTY; } this.selectedTokenService.setToken(result.token); - return this.raidenService.connectTokenNetwork( - result.funds, - result.token.address + return this.raidenService.openBatchOfChannels( + result.token, + result.connectionChoices ); }) ) From 40c4db9a0d59596013c8d8756dab0b44e8e5e900 Mon Sep 17 00:00:00 2001 From: manuelwedler Date: Thu, 19 Nov 2020 09:37:46 +0100 Subject: [PATCH 14/17] Remove functionality related to the connection manager --- src/app/app.module.ts | 2 - .../connection-manager-dialog.component.html | 23 --- .../connection-manager-dialog.component.scss | 0 ...onnection-manager-dialog.component.spec.ts | 195 ------------------ .../connection-manager-dialog.component.ts | 80 ------- .../contact-actions.component.spec.ts | 1 - .../payment-dialog.component.spec.ts | 1 - .../quick-connect-dialog.component.spec.ts | 1 - .../token-network-selector.component.spec.ts | 1 - src/app/models/connection.ts | 1 - src/app/services/raiden.service.spec.ts | 75 ------- src/app/services/raiden.service.ts | 60 ------ .../services/token-polling.service.spec.ts | 2 - src/app/utils/token.utils.spec.ts | 2 - src/testing/test-data.ts | 1 - 15 files changed, 445 deletions(-) delete mode 100644 src/app/components/connection-manager-dialog/connection-manager-dialog.component.html delete mode 100644 src/app/components/connection-manager-dialog/connection-manager-dialog.component.scss delete mode 100644 src/app/components/connection-manager-dialog/connection-manager-dialog.component.spec.ts delete mode 100644 src/app/components/connection-manager-dialog/connection-manager-dialog.component.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 53939e52..07f8922d 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -10,7 +10,6 @@ import { environment } from '../environments/environment'; import { AppComponent } from './app.component'; import { ConfirmationDialogComponent } from './components/confirmation-dialog/confirmation-dialog.component'; import { HomeComponent } from './components/home/home.component'; -import { ConnectionManagerDialogComponent } from './components/connection-manager-dialog/connection-manager-dialog.component'; import { AboutComponent } from './components/about/about.component'; import { OpenDialogComponent } from './components/open-dialog/open-dialog.component'; import { PaymentDialogComponent } from './components/payment-dialog/payment-dialog.component'; @@ -87,7 +86,6 @@ export function ConfigLoader(raidenConfig: RaidenConfig) { HomeComponent, AboutComponent, PaymentDialogComponent, - ConnectionManagerDialogComponent, RegisterDialogComponent, OpenDialogComponent, ConfirmationDialogComponent, diff --git a/src/app/components/connection-manager-dialog/connection-manager-dialog.component.html b/src/app/components/connection-manager-dialog/connection-manager-dialog.component.html deleted file mode 100644 index cac01816..00000000 --- a/src/app/components/connection-manager-dialog/connection-manager-dialog.component.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - diff --git a/src/app/components/connection-manager-dialog/connection-manager-dialog.component.scss b/src/app/components/connection-manager-dialog/connection-manager-dialog.component.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/components/connection-manager-dialog/connection-manager-dialog.component.spec.ts b/src/app/components/connection-manager-dialog/connection-manager-dialog.component.spec.ts deleted file mode 100644 index 6d745a52..00000000 --- a/src/app/components/connection-manager-dialog/connection-manager-dialog.component.spec.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; -import { ReactiveFormsModule } from '@angular/forms'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { MaterialComponentsModule } from '../../modules/material-components/material-components.module'; -import { TokenInputComponent } from '../token-input/token-input.component'; -import { - ConnectionManagerDialogComponent, - ConnectionManagerDialogPayload, -} from './connection-manager-dialog.component'; -import { TestProviders } from '../../../testing/test-providers'; -import BigNumber from 'bignumber.js'; -import { RaidenDialogComponent } from '../raiden-dialog/raiden-dialog.component'; -import { - mockInput, - clickElement, - mockOpenMatSelect, - mockMatSelectFirst, -} from '../../../testing/interaction-helper'; -import { By } from '@angular/platform-browser'; -import { DecimalPipe } from '../../pipes/decimal.pipe'; -import { DisplayDecimalsPipe } from '../../pipes/display-decimals.pipe'; -import { createToken } from '../../../testing/test-data'; -import { RaidenIconsModule } from '../../modules/raiden-icons/raiden-icons.module'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { MatSelect } from '@angular/material/select'; -import { MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { TokenNetworkSelectorComponent } from '../token-network-selector/token-network-selector.component'; -import { TokenPollingService } from '../../services/token-polling.service'; -import { of } from 'rxjs'; -import { stub } from '../../../testing/stub'; -import { BalanceWithSymbolComponent } from '../balance-with-symbol/balance-with-symbol.component'; -import { ClipboardModule } from 'ngx-clipboard'; - -describe('ConnectionManagerDialogComponent', () => { - let component: ConnectionManagerDialogComponent; - let fixture: ComponentFixture; - - const amountInput = '70'; - const token = createToken({ - decimals: 0, - balance: new BigNumber(1000), - connected: { - channels: 5, - funds: new BigNumber(10), - sum_deposits: new BigNumber(50), - }, - }); - - beforeEach( - waitForAsync(() => { - const payload: ConnectionManagerDialogPayload = { - funds: undefined, - token: token, - }; - - const tokenPollingMock = stub(); - // @ts-ignore - tokenPollingMock.tokens$ = of([token]); - tokenPollingMock.getTokenUpdates = () => of(token); - - TestBed.configureTestingModule({ - declarations: [ - ConnectionManagerDialogComponent, - TokenInputComponent, - RaidenDialogComponent, - DecimalPipe, - DisplayDecimalsPipe, - TokenNetworkSelectorComponent, - BalanceWithSymbolComponent, - ], - providers: [ - TestProviders.MockMatDialogData(payload), - TestProviders.MockMatDialogRef({ close: () => {} }), - TestProviders.MockRaidenConfigProvider(), - { - provide: TokenPollingService, - useValue: tokenPollingMock, - }, - TestProviders.AddressBookStubProvider(), - ], - imports: [ - MaterialComponentsModule, - ReactiveFormsModule, - NoopAnimationsModule, - RaidenIconsModule, - HttpClientTestingModule, - ClipboardModule, - ], - }).compileComponents(); - }) - ); - - describe('with token payload', () => { - beforeEach(() => { - fixture = TestBed.createComponent(ConnectionManagerDialogComponent); - component = fixture.componentInstance; - - fixture.detectChanges(); - }); - - it('should be created', () => { - expect(component).toBeTruthy(); - fixture.destroy(); - }); - - it('should close the dialog with the result when accept button is clicked', () => { - mockInput( - fixture.debugElement.query(By.directive(TokenInputComponent)), - 'input', - amountInput - ); - fixture.detectChanges(); - - // @ts-ignore - const closeSpy = spyOn(component.dialogRef, 'close'); - clickElement(fixture.debugElement, '#accept'); - fixture.detectChanges(); - - expect(closeSpy).toHaveBeenCalledTimes(1); - expect(closeSpy).toHaveBeenCalledWith({ - token: token, - funds: new BigNumber(amountInput), - }); - }); - - it('should close the dialog with no result when cancel button is clicked', () => { - mockInput( - fixture.debugElement.query(By.directive(TokenInputComponent)), - 'input', - amountInput - ); - fixture.detectChanges(); - - // @ts-ignore - const closeSpy = spyOn(component.dialogRef, 'close'); - clickElement(fixture.debugElement, '#cancel'); - fixture.detectChanges(); - - expect(closeSpy).toHaveBeenCalledTimes(1); - expect(closeSpy).toHaveBeenCalledWith(); - }); - - it('should set the maximum token amount to the balance', () => { - const tokenInputComponent: TokenInputComponent = fixture.debugElement.query( - By.directive(TokenInputComponent) - ).componentInstance; - expect(tokenInputComponent.maxAmount.isEqualTo(token.balance)).toBe( - true - ); - }); - }); - - describe('without token payload', () => { - beforeEach(() => { - const payload: ConnectionManagerDialogPayload = { - funds: undefined, - token: undefined, - }; - TestBed.overrideProvider(MAT_DIALOG_DATA, { useValue: payload }); - fixture = TestBed.createComponent(ConnectionManagerDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should show a token network selector', () => { - const selector = fixture.debugElement.query( - By.directive(MatSelect) - ); - expect(selector).toBeTruthy(); - expect(component.form.value.token).toBeFalsy(); - }); - - it('should not set the maximum token amount by default', () => { - const tokenInputComponent: TokenInputComponent = fixture.debugElement.query( - By.directive(TokenInputComponent) - ).componentInstance; - expect(tokenInputComponent.maxAmount).toBeUndefined(); - }); - - it('should set the maximum token amount after token selection', () => { - const networkSelectorElement = fixture.debugElement.query( - By.directive(TokenNetworkSelectorComponent) - ); - mockOpenMatSelect(networkSelectorElement); - fixture.detectChanges(); - mockMatSelectFirst(fixture.debugElement); - fixture.detectChanges(); - - const tokenInputComponent: TokenInputComponent = fixture.debugElement.query( - By.directive(TokenInputComponent) - ).componentInstance; - expect(tokenInputComponent.maxAmount.isEqualTo(1000)).toBe(true); - }); - }); -}); diff --git a/src/app/components/connection-manager-dialog/connection-manager-dialog.component.ts b/src/app/components/connection-manager-dialog/connection-manager-dialog.component.ts deleted file mode 100644 index 9c63858e..00000000 --- a/src/app/components/connection-manager-dialog/connection-manager-dialog.component.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Component, Inject, OnInit, ViewChild, OnDestroy } from '@angular/core'; -import { FormBuilder, Validators } from '@angular/forms'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { TokenInputComponent } from '../token-input/token-input.component'; -import BigNumber from 'bignumber.js'; -import { UserToken } from '../../models/usertoken'; -import { Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; -import { TokenPollingService } from '../../services/token-polling.service'; - -export interface ConnectionManagerDialogPayload { - token: UserToken; - funds: BigNumber; -} - -@Component({ - selector: 'app-join-dialog', - templateUrl: './connection-manager-dialog.component.html', - styleUrls: ['./connection-manager-dialog.component.scss'], -}) -export class ConnectionManagerDialogComponent implements OnInit, OnDestroy { - @ViewChild(TokenInputComponent, { static: true }) - private tokenInput: TokenInputComponent; - - form = this.fb.group({ - amount: ['', Validators.required], - token: [undefined, Validators.required], - }); - initiatedWithoutToken = false; - - private ngUnsubscribe = new Subject(); - - constructor( - @Inject(MAT_DIALOG_DATA) private data: ConnectionManagerDialogPayload, - private dialogRef: MatDialogRef, - private fb: FormBuilder, - private tokenPollingService: TokenPollingService - ) { - this.initiatedWithoutToken = !data.token; - } - - ngOnInit() { - if (this.data.token) { - this.tokenNetworkSelected(this.data.token); - } - } - - ngOnDestroy() { - this.ngUnsubscribe.next(); - this.ngUnsubscribe.complete(); - } - - tokenNetworkSelected(token: UserToken) { - this.form.get('token').setValue(token); - this.tokenInput.selectedToken = token; - this.subscribeToTokenUpdates(token.address); - } - - subscribeToTokenUpdates(tokenAddress: string) { - this.ngUnsubscribe.next(); - this.tokenPollingService - .getTokenUpdates(tokenAddress) - .pipe(takeUntil(this.ngUnsubscribe)) - .subscribe((updatedToken: UserToken) => { - this.tokenInput.maxAmount = updatedToken.balance; - }); - } - - accept() { - const payload: ConnectionManagerDialogPayload = { - token: this.form.value.token, - funds: this.form.value.amount, - }; - this.dialogRef.close(payload); - } - - cancel() { - this.dialogRef.close(); - } -} diff --git a/src/app/components/contact/contact-actions/contact-actions.component.spec.ts b/src/app/components/contact/contact-actions/contact-actions.component.spec.ts index ba75d0da..2ce11e74 100644 --- a/src/app/components/contact/contact-actions/contact-actions.component.spec.ts +++ b/src/app/components/contact/contact-actions/contact-actions.component.spec.ts @@ -160,7 +160,6 @@ describe('ContactActionsComponent', () => { const connectedToken = createToken({ connected: { channels: 1, - funds: new BigNumber(0), sum_deposits: new BigNumber(0), }, }); diff --git a/src/app/components/payment-dialog/payment-dialog.component.spec.ts b/src/app/components/payment-dialog/payment-dialog.component.spec.ts index 459585bd..51bf2933 100644 --- a/src/app/components/payment-dialog/payment-dialog.component.spec.ts +++ b/src/app/components/payment-dialog/payment-dialog.component.spec.ts @@ -53,7 +53,6 @@ describe('PaymentDialogComponent', () => { decimals: 0, connected: { channels: 5, - funds: new BigNumber(10), sum_deposits: new BigNumber(50), }, }); diff --git a/src/app/components/quick-connect-dialog/quick-connect-dialog.component.spec.ts b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.spec.ts index c4da009b..59d5e810 100644 --- a/src/app/components/quick-connect-dialog/quick-connect-dialog.component.spec.ts +++ b/src/app/components/quick-connect-dialog/quick-connect-dialog.component.spec.ts @@ -64,7 +64,6 @@ describe('QuickConnectDialogComponent', () => { balance: new BigNumber(1000), connected: { channels: 5, - funds: new BigNumber(10), sum_deposits: new BigNumber(50), }, }); diff --git a/src/app/components/token-network-selector/token-network-selector.component.spec.ts b/src/app/components/token-network-selector/token-network-selector.component.spec.ts index 22cea20c..ff858871 100644 --- a/src/app/components/token-network-selector/token-network-selector.component.spec.ts +++ b/src/app/components/token-network-selector/token-network-selector.component.spec.ts @@ -28,7 +28,6 @@ describe('TokenNetworkSelectorComponent', () => { const connectedToken = createToken({ connected: { channels: 5, - funds: new BigNumber(10), sum_deposits: new BigNumber(50), }, }); diff --git a/src/app/models/connection.ts b/src/app/models/connection.ts index d044b74e..5840c8ff 100644 --- a/src/app/models/connection.ts +++ b/src/app/models/connection.ts @@ -1,7 +1,6 @@ import BigNumber from 'bignumber.js'; export interface Connection { - funds: BigNumber; sum_deposits: BigNumber; channels: number; } diff --git a/src/app/services/raiden.service.spec.ts b/src/app/services/raiden.service.spec.ts index 352e9575..636091c3 100644 --- a/src/app/services/raiden.service.spec.ts +++ b/src/app/services/raiden.service.spec.ts @@ -770,80 +770,6 @@ describe('RaidenService', () => { ); }); - it('should inform the user when quick connect was successful', fakeAsync(() => { - service - .connectTokenNetwork(new BigNumber(1000), token.address) - .subscribe((value) => expect(value).toBeFalsy()) - .add(() => { - expect( - notificationService.removePendingAction - ).toHaveBeenCalledTimes(1); - }); - tick(); - - const request = mockHttp.expectOne({ - url: `${endpoint}/connections/${token.address}`, - method: 'PUT', - }); - expect(losslessParse(request.request.body)).toEqual({ - funds: new BigNumber(1000), - }); - expect(notificationService.addPendingAction).toHaveBeenCalledTimes(1); - - request.flush( - {}, - { - status: 204, - statusText: '', - } - ); - flush(); - - expect( - notificationService.addSuccessNotification - ).toHaveBeenCalledTimes(1); - })); - - it('should inform the user when quick connect was not successful', fakeAsync(() => { - service - .connectTokenNetwork(new BigNumber(1000), token.address) - .subscribe( - () => { - fail('On next should not be called'); - }, - (error) => { - expect(error).toBeTruthy('An error was expected'); - } - ) - .add(() => { - expect( - notificationService.removePendingAction - ).toHaveBeenCalledTimes(1); - }); - tick(); - - const request = mockHttp.expectOne({ - url: `${endpoint}/connections/${token.address}`, - method: 'PUT', - }); - expect(notificationService.addPendingAction).toHaveBeenCalledTimes(1); - - const errorMessage = 'Insufficient balance'; - const errorBody = { - errors: errorMessage, - }; - - request.flush(errorBody, { - status: 400, - statusText: '', - }); - flush(); - - expect(notificationService.addErrorNotification).toHaveBeenCalledTimes( - 1 - ); - })); - it('should inform the user when leaving a token network was successful', fakeAsync(() => { service .leaveTokenNetwork(token) @@ -1057,7 +983,6 @@ describe('RaidenService', () => { it('should give the tokens', fakeAsync(() => { const connection: Connection = { - funds: new BigNumber(100), sum_deposits: new BigNumber(67), channels: 3, }; diff --git a/src/app/services/raiden.service.ts b/src/app/services/raiden.service.ts index 6aaeb4dd..9b447ff9 100644 --- a/src/app/services/raiden.service.ts +++ b/src/app/services/raiden.service.ts @@ -717,66 +717,6 @@ export class RaidenService { ); } - public connectTokenNetwork( - funds: BigNumber, - tokenAddress: string - ): Observable { - let notificationIdentifier: number; - const token = this.getUserToken(tokenAddress); - const formattedAmount = amountToDecimal( - funds, - token.decimals - ).toFixed(); - - return of(null).pipe( - tap(() => { - this.quickConnectPending[tokenAddress] = true; - const message: UiMessage = { - title: 'Quick connect', - description: `${formattedAmount} ${token.symbol} funds`, - icon: 'thunderbolt', - userToken: token, - }; - notificationIdentifier = this.notificationService.addPendingAction( - message - ); - }), - switchMap(() => - this.http.put( - `${this.raidenConfig.api}/connections/${tokenAddress}`, - { - funds, - } - ) - ), - mapTo(null), - tap(() => { - const message: UiMessage = { - title: 'Quick connect successful', - description: `${formattedAmount} ${token.symbol} funds`, - icon: 'thunderbolt', - userToken: token, - }; - this.notificationService.addSuccessNotification(message); - }), - catchError((error) => { - this.notificationService.addErrorNotification({ - title: 'Quick connect failed', - description: error, - icon: 'error-mark', - userToken: token, - }); - return throwError(error); - }), - finalize(() => { - this.quickConnectPending[tokenAddress] = false; - this.notificationService.removePendingAction( - notificationIdentifier - ); - }) - ); - } - public leaveTokenNetwork(userToken: UserToken): Observable { let notificationIdentifier: number; diff --git a/src/app/services/token-polling.service.spec.ts b/src/app/services/token-polling.service.spec.ts index 6b22940c..d6fdacc8 100644 --- a/src/app/services/token-polling.service.spec.ts +++ b/src/app/services/token-polling.service.spec.ts @@ -27,14 +27,12 @@ describe('TokenPollingService', () => { createToken({ connected: { channels: 1, - funds: new BigNumber(0), sum_deposits: new BigNumber(0), }, }), createToken({ connected: { channels: 2, - funds: new BigNumber(0), sum_deposits: new BigNumber(0), }, }), diff --git a/src/app/utils/token.utils.spec.ts b/src/app/utils/token.utils.spec.ts index 3fc85375..a5dcc677 100644 --- a/src/app/utils/token.utils.spec.ts +++ b/src/app/utils/token.utils.spec.ts @@ -11,7 +11,6 @@ describe('TokenUtils', () => { balance: new BigNumber(100), sumChannelBalances: new BigNumber(100), connected: { - funds: new BigNumber(100), sum_deposits: new BigNumber(100), channels: 1, }, @@ -25,7 +24,6 @@ describe('TokenUtils', () => { balance: new BigNumber(50), sumChannelBalances: new BigNumber(30), connected: { - funds: new BigNumber(40), sum_deposits: new BigNumber(20), channels: 2, }, diff --git a/src/testing/test-data.ts b/src/testing/test-data.ts index c07c044c..985ed94d 100644 --- a/src/testing/test-data.ts +++ b/src/testing/test-data.ts @@ -52,7 +52,6 @@ export function createTestTokens(count: number = 3): UserToken[] { if (i % 2 === 0) { connected = { channels: 1, - funds: new BigNumber(0), sum_deposits: new BigNumber(0), }; } From 9fad78e7c591c75149e220f2658733e3cfc5759a Mon Sep 17 00:00:00 2001 From: manuelwedler Date: Thu, 19 Nov 2020 10:03:12 +0100 Subject: [PATCH 15/17] Add button for accessing Quick Connect directly from tokens view --- src/app/components/token/token.component.html | 37 +++++++++--------- .../components/token/token.component.spec.ts | 35 ++++++++++++++++- src/app/components/token/token.component.ts | 38 +++++++++---------- src/app/services/raiden.service.ts | 6 +-- 4 files changed, 72 insertions(+), 44 deletions(-) diff --git a/src/app/components/token/token.component.html b/src/app/components/token/token.component.html index f4ad2e59..d7e0b153 100644 --- a/src/app/components/token/token.component.html +++ b/src/app/components/token/token.component.html @@ -33,7 +33,7 @@ aria-hidden="true" > - Quick connect pending + Quick Connect pending - + +
+
-
- + +
+ +
diff --git a/src/app/components/token/token.component.spec.ts b/src/app/components/token/token.component.spec.ts index 15cf9422..f4c19c91 100644 --- a/src/app/components/token/token.component.spec.ts +++ b/src/app/components/token/token.component.spec.ts @@ -201,7 +201,7 @@ describe('TokenComponent', () => { const confirmationPayload: ConfirmationDialogPayload = { title: 'No open channels', message: - 'Do you want to use quick connect to automatically open channels?', + 'Do you want to use Quick Connect to automatically open channels?', }; const quickConnectPayload: QuickConnectDialogPayload = { token: undefined, @@ -323,7 +323,7 @@ describe('TokenComponent', () => { const confirmationPayload: ConfirmationDialogPayload = { title: `No open ${unconnectedToken.symbol} channels`, - message: `Do you want to use quick connect to automatically open ${unconnectedToken.symbol} channels?`, + message: `Do you want to use Quick Connect to automatically open ${unconnectedToken.symbol} channels?`, }; const quickConnectPayload: QuickConnectDialogPayload = { token: unconnectedToken, @@ -457,5 +457,36 @@ describe('TokenComponent', () => { expect(dialogSpy).toHaveBeenCalledTimes(1); expect(leaveSpy).toHaveBeenCalledTimes(0); }); + + it('should open quick connect dialog', () => { + selectedTokenService.setToken(unconnectedToken); + fixture.detectChanges(); + + clickElement(fixture.debugElement, '#options'); + fixture.detectChanges(); + + const dialogSpy = spyOn(dialog, 'open').and.callThrough(); + const dialogResult: QuickConnectDialogResult = { + token: unconnectedToken, + connectionChoices: createConnectionChoices(), + }; + spyOn(dialog, 'returns').and.returnValues(true, dialogResult); + spyOn(raidenService, 'openBatchOfChannels').and.returnValue( + of(null) + ); + clickElement(fixture.debugElement, '#quick-connect'); + + const payload: QuickConnectDialogPayload = { + token: unconnectedToken, + }; + expect(dialogSpy).toHaveBeenCalledTimes(1); + expect(dialogSpy).toHaveBeenCalledWith( + QuickConnectDialogComponent, + { + data: payload, + width: '400px', + } + ); + }); }); }); diff --git a/src/app/components/token/token.component.ts b/src/app/components/token/token.component.ts index 7bcce147..5c4923fe 100644 --- a/src/app/components/token/token.component.ts +++ b/src/app/components/token/token.component.ts @@ -191,25 +191,7 @@ export class TokenComponent implements OnInit, OnDestroy { }); } - private askForQuickConnect() { - const tokenSymbol = this.selectedToken?.symbol ?? ''; - const payload: ConfirmationDialogPayload = { - title: `No open ${tokenSymbol} channels`, - message: `Do you want to use quick connect to automatically open ${tokenSymbol} channels?`, - }; - - const dialog = this.dialog.open(ConfirmationDialogComponent, { - data: payload, - }); - - dialog.afterClosed().subscribe((result) => { - if (result) { - this.openQuickConnect(); - } - }); - } - - private openQuickConnect() { + openQuickConnect() { const payload: QuickConnectDialogPayload = { token: this.selectedToken, }; @@ -239,4 +221,22 @@ export class TokenComponent implements OnInit, OnDestroy { this.channelPollingService.refresh(); }); } + + private askForQuickConnect() { + const tokenSymbol = this.selectedToken?.symbol ?? ''; + const payload: ConfirmationDialogPayload = { + title: `No open ${tokenSymbol} channels`, + message: `Do you want to use Quick Connect to automatically open ${tokenSymbol} channels?`, + }; + + const dialog = this.dialog.open(ConfirmationDialogComponent, { + data: payload, + }); + + dialog.afterClosed().subscribe((result) => { + if (result) { + this.openQuickConnect(); + } + }); + } } diff --git a/src/app/services/raiden.service.ts b/src/app/services/raiden.service.ts index 9b447ff9..99c29af3 100644 --- a/src/app/services/raiden.service.ts +++ b/src/app/services/raiden.service.ts @@ -658,7 +658,7 @@ export class RaidenService { tap(() => { this.quickConnectPending[token.address] = true; const message: UiMessage = { - title: 'Quick connect', + title: 'Quick Connect', description: `${connectionChoices.length} channels on ${token.symbol}`, icon: 'thunderbolt', userToken: token, @@ -690,7 +690,7 @@ export class RaidenService { ), tap(() => { const message: UiMessage = { - title: 'Quick connect successful', + title: 'Quick Connect successful', description: `${ connectionChoices.length - errorCount } channels on ${token.symbol}`, @@ -701,7 +701,7 @@ export class RaidenService { }), catchError((error) => { this.notificationService.addErrorNotification({ - title: 'Quick connect failed', + title: 'Quick Connect failed', description: error, icon: 'error-mark', userToken: token, From 9589332300043621805c8f4bbb86d09f460e770c Mon Sep 17 00:00:00 2001 From: manuelwedler Date: Thu, 19 Nov 2020 10:57:41 +0100 Subject: [PATCH 16/17] Updates CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4867ebc1..c5e30394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Changed +- [#547] Connection manager is replaced by a new quick connect dialog which transparently opens channels with suggestions by a pathfinding service. + ### Added - [#345] Adds the input field for a payment identifier to the transfer dialog again (was removed in the redesign). @@ -176,6 +179,7 @@ token network. [0.7.0]: https://github.com/raiden-network/webui/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/raiden-network/webui/releases/tag/v0.6.0 +[#547]: https://github.com/raiden-network/webui/issues/547 [#485]: https://github.com/raiden-network/webui/issues/485 [#476]: https://github.com/raiden-network/webui/issues/476 [#475]: https://github.com/raiden-network/webui/issues/475 From f2ff5f9831c9e54946b2f3e6a69b2faf362b0369 Mon Sep 17 00:00:00 2001 From: manuelwedler Date: Wed, 2 Dec 2020 17:35:03 +0100 Subject: [PATCH 17/17] Use defer() instead of of(null) for side effects on subscribe --- src/app/services/raiden.service.ts | 233 ++++++++++++++--------------- 1 file changed, 112 insertions(+), 121 deletions(-) diff --git a/src/app/services/raiden.service.ts b/src/app/services/raiden.service.ts index 99c29af3..21a2efa0 100644 --- a/src/app/services/raiden.service.ts +++ b/src/app/services/raiden.service.ts @@ -15,6 +15,7 @@ import { Subject, throwError, forkJoin, + defer, } from 'rxjs'; import { fromPromise } from 'rxjs/internal-compatibility'; import { @@ -294,47 +295,45 @@ export class RaidenService { const partnerLabel = this.getContactLabel(partnerAddress); let notificationIdentifier: number; - return of(null).pipe( - tap(() => { - const formattedBalance = amountToDecimal( - balance, - token.decimals - ).toFixed(); - const message: UiMessage = { - title: 'Opening channel', - description: `with ${partnerLabel} ${partnerAddress} and ${formattedBalance} ${token.symbol} deposit`, - icon: 'channel', - identiconAddress: partnerAddress, - userToken: token, - }; - notificationIdentifier = this.notificationService.addPendingAction( - message - ); + return defer(() => { + const formattedBalance = amountToDecimal( + balance, + token.decimals + ).toFixed(); + const message: UiMessage = { + title: 'Opening channel', + description: `with ${partnerLabel} ${partnerAddress} and ${formattedBalance} ${token.symbol} deposit`, + icon: 'channel', + identiconAddress: partnerAddress, + userToken: token, + }; + notificationIdentifier = this.notificationService.addPendingAction( + message + ); - if (!this.pendingChannels[tokenAddress]) { - this.pendingChannels[tokenAddress] = {}; - } - this.pendingChannels[tokenAddress][partnerAddress] = { - channel_identifier: new BigNumber(0), - state: 'waiting_for_open', - total_deposit: new BigNumber(0), - total_withdraw: new BigNumber(0), - balance: new BigNumber(0), - reveal_timeout: 0, - settle_timeout: settleTimeout, - token_address: tokenAddress, - partner_address: partnerAddress, - depositPending: true, - userToken: token, - }; - this.pendingChannelsSubject.next(this.pendingChannels); - }), - switchMap(() => - this.http.put( - `${this.raidenConfig.api}/channels`, - body - ) - ), + if (!this.pendingChannels[tokenAddress]) { + this.pendingChannels[tokenAddress] = {}; + } + this.pendingChannels[tokenAddress][partnerAddress] = { + channel_identifier: new BigNumber(0), + state: 'waiting_for_open', + total_deposit: new BigNumber(0), + total_withdraw: new BigNumber(0), + balance: new BigNumber(0), + reveal_timeout: 0, + settle_timeout: settleTimeout, + token_address: tokenAddress, + partner_address: partnerAddress, + depositPending: true, + userToken: token, + }; + this.pendingChannelsSubject.next(this.pendingChannels); + + return this.http.put( + `${this.raidenConfig.api}/channels`, + body + ); + }).pipe( map((channel: Channel) => { channel.settle_timeout = (( (channel.settle_timeout) @@ -605,23 +604,21 @@ export class RaidenService { public registerToken(tokenAddress: string): Observable { let notificationIdentifier: number; - return of(null).pipe( - tap(() => { - const message: UiMessage = { - title: 'Registering token', - description: tokenAddress, - icon: 'add', - }; - notificationIdentifier = this.notificationService.addPendingAction( - message - ); - }), - switchMap(() => - this.http.put( - `${this.raidenConfig.api}/tokens/${tokenAddress}`, - {} - ) - ), + return defer(() => { + const message: UiMessage = { + title: 'Registering token', + description: tokenAddress, + icon: 'add', + }; + notificationIdentifier = this.notificationService.addPendingAction( + message + ); + + return this.http.put( + `${this.raidenConfig.api}/tokens/${tokenAddress}`, + {} + ); + }).pipe( mapTo(null), tap(() => { const message: UiMessage = { @@ -654,35 +651,33 @@ export class RaidenService { let notificationIdentifier: number; let errorCount = 0; - return of(null).pipe( - tap(() => { - this.quickConnectPending[token.address] = true; - const message: UiMessage = { - title: 'Quick Connect', - description: `${connectionChoices.length} channels on ${token.symbol}`, - icon: 'thunderbolt', - userToken: token, - }; - notificationIdentifier = this.notificationService.addPendingAction( - message - ); - }), - switchMap(() => { - const openChannelObservables = connectionChoices.map((choice) => - this.openChannel( - token.address, - choice.partnerAddress, - this.raidenConfig.config.settle_timeout, - choice.deposit - ).pipe( - catchError(() => { - errorCount++; - return of(null); - }) - ) - ); - return forkJoin(openChannelObservables); - }), + return defer(() => { + this.quickConnectPending[token.address] = true; + const message: UiMessage = { + title: 'Quick Connect', + description: `${connectionChoices.length} channels on ${token.symbol}`, + icon: 'thunderbolt', + userToken: token, + }; + notificationIdentifier = this.notificationService.addPendingAction( + message + ); + + const openChannelObservables = connectionChoices.map((choice) => + this.openChannel( + token.address, + choice.partnerAddress, + this.raidenConfig.config.settle_timeout, + choice.deposit + ).pipe( + catchError(() => { + errorCount++; + return of(null); + }) + ) + ); + return forkJoin(openChannelObservables); + }).pipe( switchMap(() => errorCount === connectionChoices.length ? throwError('All channel creations failed') @@ -720,23 +715,21 @@ export class RaidenService { public leaveTokenNetwork(userToken: UserToken): Observable { let notificationIdentifier: number; - return of(null).pipe( - tap(() => { - const message: UiMessage = { - title: 'Leaving token network', - description: `${userToken.symbol}`, - icon: 'close', - userToken: userToken, - }; - notificationIdentifier = this.notificationService.addPendingAction( - message - ); - }), - switchMap(() => - this.http.delete( - `${this.raidenConfig.api}/connections/${userToken.address}` - ) - ), + return defer(() => { + const message: UiMessage = { + title: 'Leaving token network', + description: `${userToken.symbol}`, + icon: 'close', + userToken: userToken, + }; + notificationIdentifier = this.notificationService.addPendingAction( + message + ); + + return this.http.delete( + `${this.raidenConfig.api}/connections/${userToken.address}` + ); + }).pipe( mapTo(null), tap(() => { const message: UiMessage = { @@ -800,24 +793,22 @@ export class RaidenService { let notificationIdentifier: number; const formattedAmount = amountToDecimal(amount, token.decimals); - return of(null).pipe( - tap(() => { - const message: UiMessage = { - title: 'Minting', - description: `${formattedAmount} ${token.symbol} on-chain`, - icon: 'token', - userToken: token, - }; - notificationIdentifier = this.notificationService.addPendingAction( - message - ); - }), - switchMap(() => - this.http.post( - `${this.raidenConfig.api}/_testing/tokens/${token.address}/mint`, - { to: targetAddress, value: amount } - ) - ), + return defer(() => { + const message: UiMessage = { + title: 'Minting', + description: `${formattedAmount} ${token.symbol} on-chain`, + icon: 'token', + userToken: token, + }; + notificationIdentifier = this.notificationService.addPendingAction( + message + ); + + return this.http.post( + `${this.raidenConfig.api}/_testing/tokens/${token.address}/mint`, + { to: targetAddress, value: amount } + ); + }).pipe( mapTo(null), tap(() => { const message: UiMessage = {