TD-869: New deposit details page. New create revert dialog (#334)

This commit is contained in:
Rinat Arsaev 2024-02-22 17:54:59 +07:00 committed by GitHub
parent 7e5ac50b8f
commit 1b060558bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 490 additions and 344 deletions

8
package-lock.json generated
View File

@ -25,7 +25,7 @@
"@vality/fistful-proto": "2.0.1-8ecf2b7.0",
"@vality/machinegun-proto": "1.0.0",
"@vality/magista-proto": "2.0.2-28d11b9.0",
"@vality/ng-core": "^17.2.1-pr-57-0134d85.0",
"@vality/ng-core": "^17.2.1-pr-57-3adeb57.0",
"@vality/payout-manager-proto": "2.0.1-eb4091a.0",
"@vality/repairer-proto": "2.0.2-07b73e9.0",
"@vality/thrift-ts": "2.4.1-8ad5123.0",
@ -6454,9 +6454,9 @@
"integrity": "sha512-BsDy5ejotfTtUlwuoX3kz+PYJ5NSTW6m5ZRGv+p5HaKXSjR7tserPdv0q133Wp4T+sg0ED0Qr9Peqsrn+9XlDQ=="
},
"node_modules/@vality/ng-core": {
"version": "17.2.1-pr-57-0134d85.0",
"resolved": "https://registry.npmjs.org/@vality/ng-core/-/ng-core-17.2.1-pr-57-0134d85.0.tgz",
"integrity": "sha512-0wlLzf2+smFYVYqu/iEptyeKrasRsxWRd2hNwqG+cd0eiIwxNWyBMaI0+FuWS141CtodSgF8Ab8CAG2ceD+vkw==",
"version": "17.2.1-pr-57-3adeb57.0",
"resolved": "https://registry.npmjs.org/@vality/ng-core/-/ng-core-17.2.1-pr-57-3adeb57.0.tgz",
"integrity": "sha512-h8/U6pUrJDMTXUj2HL9xC6KjlVa1kNj20DcI79gaoYmh//4L/U+y6vF75ieOeZW8MUFDD9WcVGfrwsRFWOntqQ==",
"dependencies": {
"@angular/material-date-fns-adapter": "^17.2.0",
"@ng-matero/extensions": "^17.1.0",

View File

@ -33,7 +33,7 @@
"@vality/fistful-proto": "2.0.1-8ecf2b7.0",
"@vality/machinegun-proto": "1.0.0",
"@vality/magista-proto": "2.0.2-28d11b9.0",
"@vality/ng-core": "^17.2.1-pr-57-0134d85.0",
"@vality/ng-core": "^17.2.1-pr-57-3adeb57.0",
"@vality/payout-manager-proto": "2.0.1-eb4091a.0",
"@vality/repairer-proto": "2.0.2-07b73e9.0",
"@vality/thrift-ts": "2.4.1-8ad5123.0",

View File

@ -1,7 +1,14 @@
<cc-page-layout title="Deposit details">
<cc-deposit-main-info
*ngIf="deposit$ | async as deposit"
[deposit]="deposit"
></cc-deposit-main-info>
<cc-page-layout [progress]="isLoading$ | async" title="Deposit details">
<mat-card *ngIf="deposit$ | async">
<mat-card-content>
<cc-thrift-viewer
[extensions]="extensions$ | async"
[metadata]="metadata$ | async"
[value]="deposit$ | async"
namespace="fistful_stat"
type="StatDeposit"
></cc-thrift-viewer>
</mat-card-content>
</mat-card>
<cc-reverts *ngIf="deposit$ | async as deposit" [deposit]="deposit"></cc-reverts>
</cc-page-layout>

View File

@ -1,6 +1,22 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { formatDate } from '@angular/common';
import { ChangeDetectionStrategy, Component, OnInit, Inject, LOCALE_ID } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { pluck, take } from 'rxjs/operators';
import { DepositStatus, RevertStatus } from '@vality/fistful-proto/fistful_stat';
import { Timestamp } from '@vality/fistful-proto/internal/base';
import { formatCurrency, getImportValue } from '@vality/ng-core';
import startCase from 'lodash-es/startCase';
import { of, Observable } from 'rxjs';
import { take, map } from 'rxjs/operators';
import { getUnionKey, getUnionValue } from '../../../utils';
import { ManagementService } from '../../api/wallet';
import {
MetadataViewExtension,
MetadataViewExtensionResult,
} from '../../shared/components/json-viewer';
import { isTypeWithAliases } from '../../shared/components/metadata-form';
import { AmountCurrencyService } from '../../shared/services';
import { FetchSourcesService } from '../sources';
import { ReceiveDepositService } from './services/receive-deposit/receive-deposit.service';
@ -11,15 +27,114 @@ import { ReceiveDepositService } from './services/receive-deposit/receive-deposi
})
export class DepositDetailsComponent implements OnInit {
deposit$ = this.fetchDepositService.deposit$;
isLoading$ = this.fetchDepositService.isLoading$;
metadata$ = getImportValue(import('@vality/fistful-proto/metadata.json'));
extensions$: Observable<MetadataViewExtension[]> = this.fetchDepositService.deposit$.pipe(
map((deposit) => [
{
determinant: (d) => of(isTypeWithAliases(d, 'Timestamp', 'base')),
extension: (_, value: Timestamp) =>
of({ value: formatDate(value, 'dd.MM.yyyy HH:mm:ss', 'en') }),
},
{
determinant: (d) =>
of(isTypeWithAliases(d, 'CurrencySymbolicCode', 'fistful_stat')),
extension: () => of({ hidden: true }),
},
{
determinant: (d) => of(isTypeWithAliases(d, 'Amount', 'base')),
extension: (_, amount: number) =>
this.amountCurrencyService.getCurrency(deposit.currency_symbolic_code).pipe(
map((c) => ({
value: formatCurrency(
amount,
c.data.symbolic_code,
'long',
this._locale,
c.data.exponent,
),
})),
),
},
{
determinant: (d) => of(isTypeWithAliases(d, 'DepositStatus', 'fistful_stat')),
extension: (_, status: DepositStatus) =>
of({
value: startCase(getUnionKey(status)),
tooltip: Object.keys(getUnionValue(status)).length
? getUnionValue(status)
: undefined,
tag: true,
color: (
{
failed: 'warn',
pending: 'pending',
succeeded: 'success',
} as const
)[getUnionKey(status)],
}),
},
{
determinant: (d) => of(isTypeWithAliases(d, 'RevertStatus', 'fistful_stat')),
extension: (_, status: RevertStatus, viewValue: string) =>
of({
value: startCase(viewValue),
tag: true,
color: (
{
[1]: 'pending',
[2]: 'success',
} as const
)[status],
}),
},
{
determinant: (d) => of(isTypeWithAliases(d, 'WalletID', 'fistful_stat')),
extension: (_, id: string) =>
this.walletManagementService.Get(id, {}).pipe(
map(
(wallet): MetadataViewExtensionResult => ({
value: wallet.name,
tooltip: wallet.id,
link: [
['/wallets'],
{ queryParams: { wallet_id: JSON.stringify([wallet.id]) } },
],
}),
),
),
},
{
determinant: (d) => of(isTypeWithAliases(d, 'SourceID', 'fistful_stat')),
extension: (_, id: string) =>
this.fetchSourcesService.sources$.pipe(
map((sources) => sources.find((s) => s.id === id)),
map(
(source): MetadataViewExtensionResult => ({
value: source.name,
tooltip: source.id,
}),
),
),
},
]),
);
constructor(
private fetchDepositService: ReceiveDepositService,
private route: ActivatedRoute,
@Inject(LOCALE_ID) private _locale: string,
private amountCurrencyService: AmountCurrencyService,
private walletManagementService: ManagementService,
private fetchSourcesService: FetchSourcesService,
) {}
ngOnInit() {
this.route.params
.pipe(take(1), pluck('depositID'))
.pipe(
take(1),
map((p) => p.depositID),
)
.subscribe((depositID) => this.fetchDepositService.receiveDeposit(depositID));
}
}

View File

@ -9,9 +9,10 @@ import { StatusModule, PageLayoutModule } from '@cc/app/shared/components';
import { DetailsItemModule } from '@cc/components/details-item';
import { HeadlineModule } from '@cc/components/headline';
import { ThriftViewerModule } from '../../shared/components/thrift-viewer';
import { DepositDetailsRoutingModule } from './deposit-details-routing.module';
import { DepositDetailsComponent } from './deposit-details.component';
import { DepositMainInfoModule } from './deposit-main-info/deposit-main-info.module';
import { RevertsModule } from './reverts/reverts.module';
@NgModule({
@ -25,9 +26,9 @@ import { RevertsModule } from './reverts/reverts.module';
MatProgressSpinnerModule,
MatButtonModule,
MatDialogModule,
DepositMainInfoModule,
RevertsModule,
PageLayoutModule,
ThriftViewerModule,
],
declarations: [DepositDetailsComponent],
})

View File

@ -1,26 +0,0 @@
<mat-card>
<mat-card-content style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px">
<cc-details-item style="grid-column: span 2" title="ID">{{ deposit.id }}</cc-details-item>
<cc-details-item title="Status">
<cc-status [color]="deposit.status | toStatus | toDepositColor">{{
deposit.status | toStatus
}}</cc-status>
</cc-details-item>
<cc-details-item title="Amount"
>{{ deposit.amount | ccThriftInt64 | ccFormatAmount }}
{{ deposit.currency_symbolic_code | ccCurrency }}</cc-details-item
>
<cc-details-item title="Fee"
>{{ deposit.fee | ccThriftInt64 | ccFormatAmount }}
{{ deposit.currency_symbolic_code | ccCurrency }}</cc-details-item
>
<cc-details-item title="Created At">{{
deposit.created_at | date: 'dd.MM.yyyy HH:mm:ss'
}}</cc-details-item>
<cc-details-item title="Source ID">{{ deposit.source_id }}</cc-details-item>
<cc-details-item title="Identity ID">{{ deposit.identity_id }}</cc-details-item>
<cc-details-item title="Destination">
<cc-wallet-info [walletID]="deposit.destination_id"></cc-wallet-info>
</cc-details-item>
</mat-card-content>
</mat-card>

View File

@ -1,12 +0,0 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { StatDeposit } from '@vality/fistful-proto/fistful_stat';
@Component({
selector: 'cc-deposit-main-info',
templateUrl: 'deposit-main-info.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DepositMainInfoComponent {
@Input()
deposit: StatDeposit;
}

View File

@ -1,25 +0,0 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { StatusModule } from '@cc/app/shared/components';
import { WalletInfoModule } from '@cc/app/shared/components/wallet-info';
import { CommonPipesModule, ThriftPipesModule } from '@cc/app/shared/pipes';
import { DetailsItemModule } from '@cc/components/details-item';
import { DepositMainInfoComponent } from './deposit-main-info.component';
@NgModule({
imports: [
CommonModule,
MatCardModule,
DetailsItemModule,
StatusModule,
ThriftPipesModule,
CommonPipesModule,
WalletInfoModule,
],
declarations: [DepositMainInfoComponent],
exports: [DepositMainInfoComponent],
})
export class DepositMainInfoModule {}

View File

@ -1,44 +1,15 @@
<v-dialog [progress]="!!(progress$ | async)" title="Create revert">
<div *ngIf="form" [formGroup]="form" style="display: flex; flex-direction: column; gap: 16px">
<div style="display: flex; gap: 24px">
<mat-form-field style="flex: 1">
<input
formControlName="amount"
matInput
placeholder="Amount"
required
type="number"
/>
</mat-form-field>
<mat-form-field style="flex: 1">
<input
formControlName="currency"
matInput
placeholder="Currency"
readonly
required
type="text"
/>
</mat-form-field>
</div>
<div style="display: flex; gap: 24px">
<mat-form-field style="flex: 1">
<input formControlName="reason" matInput placeholder="Reason" type="text" />
</mat-form-field>
<mat-form-field style="flex: 1">
<input
formControlName="externalID"
matInput
placeholder="External ID"
type="text"
/>
</mat-form-field>
</div>
</div>
<cc-fistful-thrift-form
[extensions]="extensions"
[formControl]="control"
namespace="deposit_revert"
noChangeKind
type="RevertParams"
></cc-fistful-thrift-form>
<v-dialog-actions>
<button
[disabled]="!!(progress$ | async) || form.invalid"
[disabled]="!!(progress$ | async) || control.invalid"
color="primary"
mat-button
(click)="createRevert()"
@ -47,3 +18,7 @@
</button>
</v-dialog-actions>
</v-dialog>
<ng-template #cashTemplate let-cashControl="control">
<cc-cash-field [formControl]="cashControl"></cc-cash-field>
</ng-template>

View File

@ -1,12 +1,25 @@
import { ChangeDetectionStrategy, Component, DestroyRef } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
viewChild,
TemplateRef,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Validators, NonNullableFormBuilder } from '@angular/forms';
import { FormControl } from '@angular/forms';
import { DepositParams } from '@vality/fistful-proto/deposit';
import { Revert } from '@vality/fistful-proto/internal/deposit_revert';
import { DialogSuperclass, NotifyLogService, toMinor, clean } from '@vality/ng-core';
import { BehaviorSubject } from 'rxjs';
import { DialogSuperclass, NotifyLogService, clean } from '@vality/ng-core';
import { BehaviorSubject, of } from 'rxjs';
import { Overwrite } from 'utility-types';
import { DepositManagementService } from '@cc/app/api/deposit';
import { Cash } from '../../../../../components/cash-field';
import {
MetadataFormExtension,
isTypeWithAliases,
} from '../../../../shared/components/metadata-form';
import { UserInfoBasedIdGeneratorService } from '../../../../shared/services';
import { CreateRevertDialogConfig } from './types/create-revert-dialog-config';
@ -21,16 +34,24 @@ export class CreateRevertDialogComponent extends DialogSuperclass<
CreateRevertDialogConfig,
Revert
> {
form = this.fb.group({
amount: [undefined as number, [Validators.pattern(/^\d+([,.]\d{1,2})?$/)]],
currency: this.dialogData.currency,
reason: undefined as string,
externalID: undefined as string,
});
control = new FormControl({
id: this.idGenerator.getUsernameBasedId(),
body: { currencyCode: this.dialogData.currency },
} as Overwrite<DepositParams, { body: Cash }>);
progress$ = new BehaviorSubject(0);
cashTemplate = viewChild<TemplateRef<unknown>>('cashTemplate');
extensions: MetadataFormExtension[] = [
{
determinant: (data) => of(isTypeWithAliases(data, 'RevertID', 'deposit_revert')),
extension: () => of({ hidden: true }),
},
{
determinant: (data) => of(isTypeWithAliases(data, 'Cash', 'base')),
extension: () => of({ template: this.cashTemplate() }),
},
];
constructor(
private fb: NonNullableFormBuilder,
private depositManagementService: DepositManagementService,
private idGenerator: UserInfoBasedIdGeneratorService,
private log: NotifyLogService,
@ -40,21 +61,17 @@ export class CreateRevertDialogComponent extends DialogSuperclass<
}
createRevert() {
const { reason, amount, currency, externalID } = this.form.value;
const { body, ...value } = this.control.value;
this.depositManagementService
.CreateRevert(
this.dialogData.depositID,
clean(
{
id: this.idGenerator.getUsernameBasedId(),
...value,
body: {
amount: toMinor(amount, currency),
currency: {
symbolic_code: currency,
},
currency: { symbolic_code: body.currencyCode },
amount: body.amount,
},
reason,
external_id: externalID,
},
false,
true,

View File

@ -10,6 +10,9 @@ import { DialogModule } from '@vality/ng-core';
import { UserInfoBasedIdGeneratorModule } from '@cc/app/shared/services/user-info-based-id-generator/user-info-based-id-generator.module';
import { CashFieldComponent } from '../../../../../components/cash-field';
import { FistfulThriftFormComponent } from '../../../../shared/components/fistful-thrift-form';
import { CreateRevertDialogComponent } from './create-revert-dialog.component';
@NgModule({
@ -23,6 +26,8 @@ import { CreateRevertDialogComponent } from './create-revert-dialog.component';
MatInputModule,
UserInfoBasedIdGeneratorModule,
DialogModule,
FistfulThriftFormComponent,
CashFieldComponent,
],
declarations: [CreateRevertDialogComponent],
})

View File

@ -4,7 +4,7 @@ import { DialogService, Column } from '@vality/ng-core';
import startCase from 'lodash-es/startCase';
import { filter } from 'rxjs/operators';
import { getDepositStatus, createCurrencyColumn } from '@cc/app/shared/utils';
import { createCurrencyColumn } from '@cc/app/shared/utils';
import { getUnionKey } from '../../../../utils';
@ -70,7 +70,7 @@ export class RevertsComponent implements OnInit {
}
isCreateRevertAvailable(status: DepositStatus): boolean {
return getDepositStatus(status) !== 'succeeded';
return getUnionKey(status) !== 'succeeded';
}
update() {

View File

@ -1,12 +1,11 @@
import { Injectable } from '@angular/core';
import { NotifyLogService } from '@vality/ng-core';
import { merge, ReplaySubject, Subject, EMPTY } from 'rxjs';
import { catchError, switchMap, shareReplay, map } from 'rxjs/operators';
import { FistfulStatisticsService, createDsl } from '@cc/app/api/fistful-stat';
import { progress } from '@cc/app/shared/custom-operators';
import { NotificationErrorService } from '../../../../shared/services/notification-error';
@Injectable()
export class ReceiveDepositService {
private receiveDeposit$ = new ReplaySubject<string>();
@ -20,7 +19,7 @@ export class ReceiveDepositService {
.pipe(
catchError((err) => {
this.error$.next(true);
this.notificationErrorService.error(err);
this.log.error(err);
return EMPTY;
}),
),
@ -34,7 +33,7 @@ export class ReceiveDepositService {
constructor(
private fistfulStatisticsService: FistfulStatisticsService,
private notificationErrorService: NotificationErrorService,
private log: NotifyLogService,
) {}
receiveDeposit(id: string) {

View File

@ -19,6 +19,6 @@
</v-dialog-actions>
</v-dialog>
<ng-template #sourceCashTemplate let-control="control">
<cc-source-cash-field [formControl]="control"></cc-source-cash-field>
<ng-template #sourceCashTemplate let-cashControl="control">
<cc-source-cash-field [formControl]="cashControl"></cc-source-cash-field>
</ng-template>

View File

@ -58,7 +58,10 @@ export class DepositsComponent implements OnInit {
columns: Column<StatDeposit>[] = [
{
field: 'id',
formatter: (d) => d.description || `#${d.id}`,
link: (d) => `/deposits/${d.id}`,
description: 'id',
maxWidth: 'max(300px, 30vw)',
},
{
field: 'status',

View File

@ -1,35 +1,34 @@
<ng-container *ngIf="view?.items$ | async as items">
<ng-container *ngIf="!extension?.hidden">
<div
*ngIf="!(view.isValue$ | async); else onlyValue"
style="display: grid; grid-template-columns: 1fr; gap: 16px"
>
<div
*ngIf="(view.leaves$ | async)?.length as count"
[ngStyle]="{
display: 'grid',
gap: '16px',
'grid-template-columns':
count === 1 ? '1fr' : count === 2 ? '1fr 1fr' : '1fr 1fr 1fr'
}"
>
<div
*ngFor="let item of view.leaves$ | async; let i = index"
style="display: grid; grid-template-rows: auto 1fr; gap: 8px"
>
<cc-key [keys]="item.path$ | async" class="mat-caption mat-secondary-text"></cc-key>
<div *ngIf="(view.leaves$ | async)?.length as count" class="grid-container">
<div [ngClass]="['grid', 'grid-columns-' + count]">
<ng-container *ngFor="let item of view.leaves$ | async; let i = index">
<div
*ngIf="!((item.current$ | async)?.extension$ | async)?.hidden"
style="display: grid; grid-template-rows: auto 1fr; gap: 8px"
>
<cc-key
[keys]="item.path$ | async"
class="mat-caption mat-secondary-text"
></cc-key>
<div *ngIf="item.current$ | async as current" class="mat-body-1">
<cc-json-viewer
*ngIf="!(current.isEmpty$ | async); else empty"
[data]="current.data$ | async"
[extension]="current.extension$ | async"
[extensions]="extensions"
[value]="current.value$ | async"
></cc-json-viewer>
<ng-template #empty>
<mat-icon class="mat-secondary-text" inline>hide_source</mat-icon>
</ng-template>
</div>
<div *ngIf="item.current$ | async as current" class="mat-body-1">
<cc-json-viewer
*ngIf="!(current.isEmpty$ | async); else empty"
[data]="current.data$ | async"
[extension]="current.extension$ | async"
[extensions]="extensions"
[value]="current.value$ | async"
></cc-json-viewer>
<ng-template #empty>
<mat-icon class="mat-secondary-text" inline>hide_source</mat-icon>
</ng-template>
</div>
</div>
</ng-container>
</div>
</div>
@ -68,25 +67,36 @@
</div>
</ng-container>
</div>
<ng-template #onlyValue>
<span
[ngClass]="{
link: !!extension?.click || !!extension?.link?.[0],
'tooltip-link': !!extension?.tooltip,
'mat-body-1': true
}"
[queryParams]="extensionQueryParams$ | async"
[routerLink]="extension?.link?.[0]"
(click)="extension?.click?.()"
>
<span
*ngIf="extension?.tooltip; else simpleValue"
[matTooltip]="getTooltip(extension.tooltip)"
matTooltipClass="tooltip"
>{{ view.renderValue$ | async }}</span
>
<ng-template #simpleValue>{{ view.renderValue$ | async }}</ng-template>
</span>
</ng-template>
</ng-container>
<ng-template #onlyValue>
<span
[queryParams]="extensionQueryParams$ | async"
[routerLink]="extension?.link?.[0]"
(click)="extension?.click?.()"
>
<span
*ngIf="extension?.tooltip; else tagValue"
[matTooltip]="getTooltip(extension.tooltip)"
matTooltipClass="tooltip"
>
<ng-container *ngTemplateOutlet="tagValue"></ng-container>
</span>
<ng-template #tagValue>
<v-tag *ngIf="extension?.tag; else simpleValue" [color]="extension?.color">
<ng-container *ngTemplateOutlet="simpleValue"></ng-container>
</v-tag>
</ng-template>
<ng-template #simpleValue>
<span
[ngClass]="{
link: !!extension?.click || !!extension?.link?.[0],
'tooltip-link': !!extension?.tooltip,
'mat-body-1': !extension?.tag
}"
>
{{ view.renderValue$ | async }}
</span>
</ng-template>
</span>
</ng-template>

View File

@ -7,6 +7,7 @@ import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { TagModule } from '@vality/ng-core';
import { ThriftPipesModule } from '@cc/app/shared';
import { DetailsItemModule } from '@cc/components/details-item';
@ -28,6 +29,7 @@ import { JsonViewerComponent } from './json-viewer.component';
MatTooltipModule,
MatBadgeModule,
RouterModule,
TagModule,
],
})
export class JsonViewerModule {}

View File

@ -11,11 +11,61 @@
.link,
.tooltip-link {
text-decoration: underline;
}
.tooltip-link {
text-decoration-style: dotted;
cursor: default;
}
.link {
text-decoration-style: solid !important;
cursor: pointer !important;
text-decoration-style: solid;
cursor: pointer;
}
.grid-container {
container-type: inline-size;
.grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(4, 1fr);
&.grid-columns-1 {
grid-template-columns: 1fr;
}
&.grid-columns-2 {
grid-template-columns: repeat(2, 1fr);
}
&.grid-columns-3 {
grid-template-columns: repeat(3, 1fr);
}
}
}
@container (width < 1200px) {
.grid-container > .grid {
grid-template-columns: repeat(3, 1fr);
}
}
@container (width < 900px) {
.grid-container > .grid {
&,
&.grid-columns-3 {
grid-template-columns: repeat(2, 1fr);
}
}
}
@container (width < 600px) {
.grid-container > .grid {
&,
&.grid-columns-3,
&.grid-columns-2 {
grid-template-columns: 1fr;
}
}
}

View File

@ -1,4 +1,5 @@
import { Router } from '@angular/router';
import { Color } from '@vality/ng-core';
import { Observable, combineLatest, switchMap, of } from 'rxjs';
import { map } from 'rxjs/operators';
@ -6,26 +7,34 @@ import { MetadataFormData } from '../../metadata-form';
export interface MetadataViewExtensionResult {
key?: string;
value: string;
value?: string;
hidden?: boolean;
tooltip?: unknown;
link?: Parameters<Router['navigate']>;
click?: () => void;
color?: Color;
tag?: boolean;
}
export type MetadataViewExtension = {
determinant: (data: MetadataFormData, value: unknown) => Observable<boolean>;
extension: (data: MetadataFormData, value: unknown) => Observable<MetadataViewExtensionResult>;
extension: (
data: MetadataFormData,
value: unknown,
viewValue: unknown,
) => Observable<MetadataViewExtensionResult>;
};
export function getFirstDeterminedExtensionsResult(
sourceExtensions: MetadataViewExtension[],
data: MetadataFormData,
value: unknown,
viewValue: unknown,
): Observable<MetadataViewExtensionResult> {
return sourceExtensions?.length
? combineLatest(sourceExtensions.map(({ determinant }) => determinant(data, value))).pipe(
map((determined) => sourceExtensions.find((_, idx) => determined[idx])),
switchMap((extension) => extension?.extension(data, value) ?? of(null)),
switchMap((extension) => extension?.extension(data, value, viewValue) ?? of(null)),
)
: of(null);
}

View File

@ -2,8 +2,8 @@ import { isEmpty } from '@vality/ng-core';
import { SetType, ListType, MapType, ValueType } from '@vality/thrift-ts';
import isNil from 'lodash-es/isNil';
import isObject from 'lodash-es/isObject';
import { Observable, of, switchMap, combineLatest } from 'rxjs';
import { map, shareReplay } from 'rxjs/operators';
import { Observable, of, switchMap, combineLatest, defer } from 'rxjs';
import { map, shareReplay, distinctUntilChanged, startWith } from 'rxjs/operators';
import { MetadataFormData } from '../../metadata-form';
@ -12,42 +12,34 @@ import { getEntries } from './get-entries';
import {
MetadataViewExtension,
getFirstDeterminedExtensionsResult,
MetadataViewExtensionResult,
} from './metadata-view-extension';
export class MetadataViewItem {
extension$ = getFirstDeterminedExtensionsResult(this.extensions, this.data, this.value).pipe(
extension$: Observable<MetadataViewExtensionResult> = defer(() => this.renderValue$).pipe(
switchMap((viewValue) =>
getFirstDeterminedExtensionsResult(this.extensions, this.data, this.value, viewValue),
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
data$ = this.extension$.pipe(
startWith(null),
map((ext) => (ext ? null : this.data)),
shareReplay({ refCount: true, bufferSize: 1 }),
);
data$ = this.extension$.pipe(map((ext) => (ext ? null : this.data)));
key$ = this.extension$.pipe(
map((ext) => (isNil(ext?.key) ? this.key : new MetadataViewItem(ext.key))),
shareReplay({ refCount: true, bufferSize: 1 }),
);
value$ = this.extension$.pipe(
map((ext) => {
const value = ext?.value ?? this.value;
return isEmpty(value) ? null : value;
}),
startWith(null),
map((ext) => this.getValue(ext)),
shareReplay({ refCount: true, bufferSize: 1 }),
);
renderValue$ = combineLatest([this.value$, this.data$]).pipe(
map(([value, data]) => {
if (data?.trueTypeNode?.data?.objectType === 'enum') {
return (
(data.trueTypeNode.data as MetadataFormData<ValueType, 'enum'>).ast.items.find(
(i, idx) => {
if ('value' in i) {
return i.value === value;
}
return idx === value;
},
).name ?? value
);
}
if (data?.objectType === 'union' && isEmpty(getEntries(value)?.[0]?.[1])) {
return getEntries(value)?.[0]?.[0];
}
return value;
}),
map(([value, data]) => this.getRenderValue(value, data)),
distinctUntilChanged(),
shareReplay({ refCount: true, bufferSize: 1 }),
);
isEmpty$ = this.renderValue$.pipe(map((value) => isEmpty(value)));
@ -199,4 +191,28 @@ export class MetadataViewItem {
}),
);
}
private getValue(ext: MetadataViewExtensionResult) {
const value = ext?.value ?? this.value;
return isEmpty(value) || ext?.hidden ? null : value;
}
private getRenderValue(value: unknown, data: MetadataFormData) {
if (data?.trueTypeNode?.data?.objectType === 'enum') {
return (
(data.trueTypeNode.data as MetadataFormData<ValueType, 'enum'>).ast.items.find(
(i, idx) => {
if ('value' in i) {
return i.value === value;
}
return idx === value;
},
).name ?? value
);
}
if (data?.objectType === 'union' && isEmpty(getEntries(value)?.[0]?.[1])) {
return getEntries(value)?.[0]?.[0];
}
return value;
}
}

View File

@ -24,7 +24,7 @@ import {
import { JsonViewerModule } from '@cc/app/shared/components/json-viewer';
import { ThriftPipesModule } from '@cc/app/shared/pipes/thrift';
import { ValueTypeTitleModule } from '@cc/app/shared/pipes/value-type-title';
import { CashModule } from '@cc/components/cash-field';
import { CashFieldComponent } from '@cc/components/cash-field';
import { ComplexFormComponent } from './components/complex-form/complex-form.component';
import { EnumFieldComponent } from './components/enum-field/enum-field.component';
@ -58,7 +58,7 @@ import { FieldLabelPipe } from './pipes/field-label.pipe';
MatDatepickerModule,
DatetimeFieldModule,
PipesModule,
CashModule,
CashFieldComponent,
AutocompleteFieldModule,
TagModule,
],

View File

@ -1,9 +0,0 @@
import { DepositStatus } from '@vality/fistful-proto/fistful_stat';
import { clearNullFields } from '@cc/utils/thrift-utils';
/**
* @deprecated use get union key
*/
export const getDepositStatus = (status: DepositStatus): string =>
Object.keys(clearNullFields(status))[0];

View File

@ -1,4 +1,2 @@
export * from './polling-conditions';
export * from './deposit-status';
export * from './clean-thrift';
export * from './table';

View File

@ -1,6 +0,0 @@
import { StatDeposit } from '@vality/fistful-proto/fistful_stat';
import { getDepositStatus } from './deposit-status';
export const createDepositStopPollingCondition = (deposit: StatDeposit): boolean =>
!!deposit && getDepositStatus(deposit.status) !== 'pending';

View File

@ -9,25 +9,10 @@
matInput
/>
</mat-form-field>
<mat-form-field style="min-width: 28px; width: 50%">
<mat-label>Currency</mat-label>
<input
[formControl]="currencyCodeControl"
[inputMask]="currencyMask"
[matAutocomplete]="auto"
[required]="required"
matInput
/>
<mat-autocomplete
#auto="matAutocomplete"
(optionSelected)="this.currencyCodeControl.setValue($event.option.value)"
>
<mat-option
*ngFor="let currency of currencies$ | async"
[value]="currency.data.symbolic_code"
>
{{ currency.data.symbolic_code }} ({{ currency.data.name }})
</mat-option>
</mat-autocomplete>
</mat-form-field>
<v-select-field
[formControl]="currencyControl"
[options]="options$ | async"
[style.width.px]="200"
label="Currency"
></v-select-field>
</div>

View File

@ -1,8 +1,7 @@
import { getCurrencySymbol } from '@angular/common';
import { getCurrencySymbol, CommonModule } from '@angular/common';
import {
Component,
Input,
Injector,
Inject,
LOCALE_ID,
OnInit,
@ -10,14 +9,26 @@ import {
DestroyRef,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Validator, ValidationErrors, FormControl } from '@angular/forms';
import { createMask } from '@ngneat/input-mask';
import { FormComponentSuperclass, createControlProviders, getValueChanges } from '@vality/ng-core';
import sortBy from 'lodash-es/sortBy';
import { Validator, ValidationErrors, FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatFormField } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { createMask, InputMaskModule } from '@ngneat/input-mask';
import { CurrencyObject } from '@vality/domain-proto/domain';
import {
FormComponentSuperclass,
createControlProviders,
getValueChanges,
Option,
SelectFieldModule,
toMinorByExponent,
toMajorByExponent,
compareDifferentTypes,
} from '@vality/ng-core';
import isNil from 'lodash-es/isNil';
import { combineLatest } from 'rxjs';
import { map, switchMap, first, distinctUntilChanged } from 'rxjs/operators';
import { map, distinctUntilChanged, shareReplay, startWith, take } from 'rxjs/operators';
import { DomainStoreService } from '@cc/app/api/domain-config';
import { DomainStoreService } from '../../app/api/domain-config';
export interface Cash {
amount: number;
@ -25,104 +36,147 @@ export interface Cash {
}
const GROUP_SEPARATOR = ' ';
const DEFAULT_EXPONENT = 2;
const RADIX_POINT = '.';
@Component({
standalone: true,
selector: 'cc-cash-field',
templateUrl: './cash-field.component.html',
providers: createControlProviders(() => CashFieldComponent),
imports: [
MatFormField,
ReactiveFormsModule,
InputMaskModule,
SelectFieldModule,
CommonModule,
MatInputModule,
],
})
export class CashFieldComponent extends FormComponentSuperclass<Cash> implements Validator, OnInit {
@Input() label?: string;
@Input({ transform: booleanAttribute }) required: boolean = false;
@Input({ transform: booleanAttribute }) minor: boolean = false;
amountControl = new FormControl<string>(null);
currencyCodeControl = new FormControl<string>(null);
currencyControl = new FormControl<CurrencyObject>(null);
currencies$ = combineLatest([
getValueChanges(this.currencyCodeControl),
this.domainStoreService.getObjects('currency'),
]).pipe(
map(([code, currencies]) =>
sortBy(currencies, 'data', 'symbolic_code').filter(
(c) =>
c.data.symbolic_code.toUpperCase().includes(code) || c.data.name.includes(code),
),
options$ = this.domainStoreService.getObjects('currency').pipe(
startWith([] as CurrencyObject[]),
map((objs): Option<CurrencyObject>[] =>
objs
.sort((a, b) => compareDifferentTypes(a.data.symbolic_code, b.data.symbolic_code))
.map((s) => ({
label: s.data.symbolic_code,
description: s.data.name,
value: s,
})),
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
amountMask$ = getValueChanges(this.currencyCodeControl).pipe(
switchMap((code) => this.getCurrencyByCode(code)),
map((c) => (this.minor ? 0 : c?.data?.exponent || 2)),
currencyExponent$ = getValueChanges(this.currencyControl).pipe(
map((obj) => obj?.data?.exponent ?? DEFAULT_EXPONENT),
distinctUntilChanged(),
map((digits) =>
shareReplay({ refCount: true, bufferSize: 1 }),
);
amountMask$ = this.currencyExponent$.pipe(
distinctUntilChanged(),
map((exponent) =>
createMask({
alias: 'numeric',
groupSeparator: GROUP_SEPARATOR,
digits,
digits: exponent,
digitsOptional: true,
placeholder: '',
onBeforePaste: (pastedValue: string) =>
this.convertPastedToStringNumber(pastedValue),
}),
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
currencyMask = createMask({ mask: 'AAA', placeholder: '' });
get currencyCode() {
return this.currencyControl.value?.data?.symbolic_code;
}
get prefix() {
return getCurrencySymbol(this.currencyCodeControl.value, 'narrow', this._locale);
return getCurrencySymbol(this.currencyCode, 'narrow', this._locale);
}
constructor(
injector: Injector,
@Inject(LOCALE_ID) private _locale: string,
private domainStoreService: DomainStoreService,
private destroyRef: DestroyRef,
private domainStoreService: DomainStoreService,
) {
super(injector);
super();
}
ngOnInit() {
super.ngOnInit();
combineLatest([
getValueChanges(this.currencyCodeControl),
getValueChanges(this.amountControl),
combineLatest([getValueChanges(this.amountControl), this.currencyExponent$]).pipe(
map(([amountStr, exponent]) => {
const amount = amountStr
? Number(amountStr.replaceAll(GROUP_SEPARATOR, ''))
: null;
return isNil(amount) ? null : toMinorByExponent(amount, exponent);
}),
distinctUntilChanged(),
),
getValueChanges(this.currencyControl).pipe(
map((obj) => obj?.data?.symbolic_code),
distinctUntilChanged(),
),
])
.pipe(
switchMap(([currencyCode]) => this.getCurrencyByCode(currencyCode)),
map(([amount, currencyCode]) =>
!isNil(amount) && currencyCode ? { amount, currencyCode } : null,
),
distinctUntilChanged(),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((currency) => {
const amountStr = this.amountControl.value;
if (amountStr && currency && !this.validate()) {
const [whole, fractional] = amountStr.split('.');
if (fractional?.length > currency.data.exponent) {
this.amountControl.setValue(
`${whole}.${fractional.slice(0, currency.data.exponent)}`,
);
}
const amount = Number(this.amountControl.value.replaceAll(GROUP_SEPARATOR, ''));
this.emitOutgoingValue({ amount, currencyCode: currency.data.symbolic_code });
} else {
this.emitOutgoingValue(null);
}
.subscribe((value) => {
this.emitOutgoingValue(value);
});
}
validate(): ValidationErrors | null {
return !this.amountControl.value || this.currencyCodeControl.value?.length !== 3
return !this.amountControl.value || !this.currencyControl.value
? { invalidCash: true }
: null;
}
handleIncomingValue(value: Cash) {
this.amountControl.setValue(
typeof value?.amount === 'number' ? String(value.amount) : null,
);
this.currencyCodeControl.setValue(value?.currencyCode);
const { currencyCode, amount } = value || {};
if (!currencyCode) {
this.setValues(amount, null);
}
this.options$
.pipe(
map(
(options) =>
options.find((o) => o.value?.data?.symbolic_code === value.currencyCode)
?.value ?? null,
),
take(1),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((obj) => {
this.setValues(amount, obj);
});
}
private getCurrencyByCode(currencyCode: string) {
return this.domainStoreService.getObjects('currency').pipe(
map((c) => c.find((v) => v.data.symbolic_code === currencyCode)),
first(),
private setValues(amount: number, currencyObject: CurrencyObject) {
this.currencyControl.setValue(currencyObject);
this.amountControl.setValue(
typeof amount === 'number'
? String(
toMajorByExponent(amount, currencyObject?.data?.exponent ?? DEFAULT_EXPONENT),
)
: null,
);
}
private convertPastedToStringNumber(pastedValue: string) {
return pastedValue.replaceAll(',', RADIX_POINT);
}
}

View File

@ -1,22 +0,0 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatInputModule } from '@angular/material/input';
import { InputMaskModule } from '@ngneat/input-mask';
import { CashFieldComponent } from './cash-field.component';
@NgModule({
declarations: [CashFieldComponent],
imports: [
CommonModule,
FormsModule,
MatInputModule,
MatAutocompleteModule,
InputMaskModule,
ReactiveFormsModule,
],
exports: [CashFieldComponent],
})
export class CashModule {}

View File

@ -1,2 +1 @@
export * from './cash-field.module';
export * from './cash-field.component';

View File

@ -25,7 +25,7 @@ import {
} from '@vality/ng-core';
import isNil from 'lodash-es/isNil';
import { combineLatest, switchMap, of } from 'rxjs';
import { map, first, distinctUntilChanged, shareReplay, startWith } from 'rxjs/operators';
import { map, distinctUntilChanged, shareReplay, startWith, take } from 'rxjs/operators';
import { DomainStoreService } from '../../app/api/domain-config';
import { FetchSourcesService } from '../../app/sections/sources';
@ -36,6 +36,7 @@ export interface SourceCash {
}
const GROUP_SEPARATOR = ' ';
const DEFAULT_EXPONENT = 2;
const RADIX_POINT = '.';
@Component({
@ -159,7 +160,7 @@ export class SourceCashFieldComponent
switchMap((s) =>
combineLatest([of(s), this.getCurrencyExponent(s?.currency_symbolic_code)]),
),
first(),
take(1),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(([source, exponent]) => {
@ -174,12 +175,12 @@ export class SourceCashFieldComponent
map(
(currencies) =>
currencies.find((c) => c.data.symbolic_code === symbolicCode)?.data
?.exponent ?? 2,
?.exponent ?? DEFAULT_EXPONENT,
),
);
}
private setValues(amount: number, source: StatSource, exponent: number = 2) {
private setValues(amount: number, source: StatSource, exponent: number = DEFAULT_EXPONENT) {
this.sourceControl.setValue(source);
this.amountControl.setValue(
typeof amount === 'number' ? String(toMajorByExponent(amount, exponent)) : null,