TD-844: New create deposit (#324)

This commit is contained in:
Rinat Arsaev 2024-02-14 20:34:48 +07:00 committed by GitHub
parent 78204f8696
commit 52e8d642f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 384 additions and 263 deletions

8
package-lock.json generated
View File

@ -22,7 +22,7 @@
"@ngneat/input-mask": "6.0.0", "@ngneat/input-mask": "6.0.0",
"@vality/deanonimus-proto": "2.0.1-2a02d87.0", "@vality/deanonimus-proto": "2.0.1-2a02d87.0",
"@vality/domain-proto": "2.0.1-decfa45.0", "@vality/domain-proto": "2.0.1-decfa45.0",
"@vality/fistful-proto": "2.0.1-23e9ba3.0", "@vality/fistful-proto": "2.0.1-8ecf2b7.0",
"@vality/machinegun-proto": "1.0.0", "@vality/machinegun-proto": "1.0.0",
"@vality/magista-proto": "2.0.2-28d11b9.0", "@vality/magista-proto": "2.0.2-28d11b9.0",
"@vality/ng-core": "^17.1.1-pr-57-4da820a.0", "@vality/ng-core": "^17.1.1-pr-57-4da820a.0",
@ -6430,9 +6430,9 @@
} }
}, },
"node_modules/@vality/fistful-proto": { "node_modules/@vality/fistful-proto": {
"version": "2.0.1-23e9ba3.0", "version": "2.0.1-8ecf2b7.0",
"resolved": "https://registry.npmjs.org/@vality/fistful-proto/-/fistful-proto-2.0.1-23e9ba3.0.tgz", "resolved": "https://registry.npmjs.org/@vality/fistful-proto/-/fistful-proto-2.0.1-8ecf2b7.0.tgz",
"integrity": "sha512-yOEV1tR7977GWLVUKThSYmC7KnwaswIqQpIacW5ffUdKMCvp+uv9LZZHpTd812k6WZXcj/NbGMIdEteQHqCLXA==" "integrity": "sha512-F2nR/OIq3dFhVW+Y1hQROMtma5Pyj50grS2GNs8gvNbKzlEIMN8Osvdr6Z5khFbuctfB8GetuWldg/fGm3k8KQ=="
}, },
"node_modules/@vality/machinegun-proto": { "node_modules/@vality/machinegun-proto": {
"version": "1.0.0", "version": "1.0.0",

View File

@ -30,7 +30,7 @@
"@ngneat/input-mask": "6.0.0", "@ngneat/input-mask": "6.0.0",
"@vality/deanonimus-proto": "2.0.1-2a02d87.0", "@vality/deanonimus-proto": "2.0.1-2a02d87.0",
"@vality/domain-proto": "2.0.1-decfa45.0", "@vality/domain-proto": "2.0.1-decfa45.0",
"@vality/fistful-proto": "2.0.1-23e9ba3.0", "@vality/fistful-proto": "2.0.1-8ecf2b7.0",
"@vality/machinegun-proto": "1.0.0", "@vality/machinegun-proto": "1.0.0",
"@vality/magista-proto": "2.0.2-28d11b9.0", "@vality/magista-proto": "2.0.2-28d11b9.0",
"@vality/ng-core": "^17.1.1-pr-57-4da820a.0", "@vality/ng-core": "^17.1.1-pr-57-4da820a.0",

View File

@ -4,9 +4,10 @@ import {
ThriftAstMetadata, ThriftAstMetadata,
deposit_Management, deposit_Management,
} from '@vality/fistful-proto'; } from '@vality/fistful-proto';
import { DepositID } from '@vality/fistful-proto/deposit'; import { DepositID, DepositParams } from '@vality/fistful-proto/deposit';
import { AdjustmentParams, AdjustmentState } from '@vality/fistful-proto/deposit_adjustment'; import { AdjustmentParams, AdjustmentState } from '@vality/fistful-proto/deposit_adjustment';
import { RevertParams, RevertState } from '@vality/fistful-proto/deposit_revert'; import { RevertParams, RevertState } from '@vality/fistful-proto/deposit_revert';
import { ContextSet } from '@vality/fistful-proto/internal/context';
import { combineLatest, from, map, Observable, switchMap } from 'rxjs'; import { combineLatest, from, map, Observable, switchMap } from 'rxjs';
import { KeycloakTokenInfoService, toWachterHeaders } from '@cc/app/shared/services'; import { KeycloakTokenInfoService, toWachterHeaders } from '@cc/app/shared/services';
@ -15,7 +16,7 @@ import { environment } from '@cc/environments/environment';
import { ConfigService } from '../../core/config.service'; import { ConfigService } from '../../core/config.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ManagementService { export class DepositManagementService {
private client$: Observable<deposit_ManagementCodegenClient>; private client$: Observable<deposit_ManagementCodegenClient>;
constructor( constructor(
@ -51,4 +52,9 @@ export class ManagementService {
CreateRevert(id: DepositID, params: RevertParams): Observable<RevertState> { CreateRevert(id: DepositID, params: RevertParams): Observable<RevertState> {
return this.client$.pipe(switchMap((c) => c.CreateRevert(id, params))); return this.client$.pipe(switchMap((c) => c.CreateRevert(id, params)));
} }
// eslint-disable-next-line @typescript-eslint/naming-convention
Create(params: DepositParams, context: ContextSet) {
return this.client$.pipe(switchMap((c) => c.Create(params, context)));
}
} }

View File

@ -1 +1 @@
export * from './management.service'; export * from './deposit-management.service';

View File

@ -5,7 +5,7 @@ import { Revert } from '@vality/fistful-proto/internal/deposit_revert';
import { DialogSuperclass, NotifyLogService, toMinor, clean } from '@vality/ng-core'; import { DialogSuperclass, NotifyLogService, toMinor, clean } from '@vality/ng-core';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { ManagementService } from '@cc/app/api/deposit'; import { DepositManagementService } from '@cc/app/api/deposit';
import { UserInfoBasedIdGeneratorService } from '../../../../shared/services'; import { UserInfoBasedIdGeneratorService } from '../../../../shared/services';
@ -31,7 +31,7 @@ export class CreateRevertDialogComponent extends DialogSuperclass<
constructor( constructor(
private fb: NonNullableFormBuilder, private fb: NonNullableFormBuilder,
private depositManagementService: ManagementService, private depositManagementService: DepositManagementService,
private idGenerator: UserInfoBasedIdGeneratorService, private idGenerator: UserInfoBasedIdGeneratorService,
private log: NotifyLogService, private log: NotifyLogService,
private destroyRef: DestroyRef, private destroyRef: DestroyRef,

View File

@ -1,33 +1,24 @@
<v-dialog [progress]="isLoading$ | async" title="Create deposit"> <v-dialog [progress]="progress$ | async" title="Create deposit">
<form *ngIf="form" [formGroup]="form" style="display: flex; flex-direction: column; gap: 16px"> <cc-fistful-thrift-form
<mat-form-field> [defaultValue]="getDefaultValue()"
<input formControlName="destination" matInput placeholder="Destination" required /> [extensions]="extensions"
</mat-form-field> [formControl]="control"
<div style="display: flex; gap: 24px"> namespace="deposit"
<mat-form-field style="flex: 1"> noChangeKind
<input type="DepositParams"
formControlName="amount" ></cc-fistful-thrift-form>
matInput
placeholder="Amount"
required
type="number"
/>
</mat-form-field>
<cc-currency-source-field
formControlName="currency"
required
style="flex: 1"
></cc-currency-source-field>
</div>
</form>
<v-dialog-actions> <v-dialog-actions>
<button <button
[disabled]="(isLoading$ | async) || form.invalid" [disabled]="(progress$ | async) || control.invalid"
color="primary" color="primary"
mat-button mat-button
(click)="createDeposit()" (click)="create()"
> >
Create Create
</button> </button>
</v-dialog-actions> </v-dialog-actions>
</v-dialog> </v-dialog>
<ng-template #sourceCashTemplate let-control="control">
<cc-source-cash-field [formControl]="control"></cc-source-cash-field>
</ng-template>

View File

@ -1,8 +0,0 @@
mat-dialog-actions {
margin-bottom: -24px;
padding: 0;
}
button {
height: 36px;
}

View File

@ -1,63 +1,85 @@
import { ChangeDetectionStrategy, Component, OnInit, DestroyRef } from '@angular/core'; import { Component, DestroyRef, ViewChild, TemplateRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { UntypedFormGroup } from '@angular/forms'; import { FormControl, Validators } from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar'; import { DepositParams } from '@vality/fistful-proto/deposit';
import { StatDeposit } from '@vality/fistful-proto/fistful_stat'; import { DialogSuperclass, NotifyLogService, progressTo } from '@vality/ng-core';
import { DialogSuperclass } from '@vality/ng-core'; import { BehaviorSubject, of } from 'rxjs';
import { first, map, switchMap } from 'rxjs/operators';
import { Overwrite } from 'utility-types';
import { CreateDepositService } from './services/create-deposit/create-deposit.service'; import { SourceCash } from '../../../../components/source-cash-field';
import { DepositManagementService } from '../../../api/deposit';
import { MetadataFormExtension, isTypeWithAliases } from '../../../shared/components/metadata-form';
import { UserInfoBasedIdGeneratorService } from '../../../shared/services';
import { FetchSourcesService } from '../../sources';
@Component({ @Component({
templateUrl: 'create-deposit-dialog.component.html', templateUrl: 'create-deposit-dialog.component.html',
styleUrls: ['create-deposit-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [CreateDepositService],
}) })
export class CreateDepositDialogComponent export class CreateDepositDialogComponent extends DialogSuperclass<CreateDepositDialogComponent> {
extends DialogSuperclass<CreateDepositDialogComponent, void, StatDeposit> @ViewChild('sourceCashTemplate') sourceCashTemplate: TemplateRef<unknown>;
implements OnInit
control = new FormControl(this.getDefaultValue(), [Validators.required]);
progress$ = new BehaviorSubject(0);
extensions: MetadataFormExtension[] = [
{ {
form: UntypedFormGroup; determinant: (data) =>
depositCreated$ = this.createDepositService.depositCreated$; of(
isLoading$ = this.createDepositService.isLoading$; isTypeWithAliases(data, 'ContextSet', 'context') ||
error$ = this.createDepositService.error$; isTypeWithAliases(data, 'DepositID', 'deposit') ||
pollingError$ = this.createDepositService.pollingError$; isTypeWithAliases(data, 'SourceID', 'deposit'),
pollingTimeout$ = this.createDepositService.pollingTimeout$; ),
extension: () => of({ hidden: true }),
},
{
determinant: (data) => of(isTypeWithAliases(data, 'Cash', 'base')),
extension: () => of({ template: this.sourceCashTemplate }),
},
];
constructor( constructor(
private createDepositService: CreateDepositService,
private snackBar: MatSnackBar,
private destroyRef: DestroyRef, private destroyRef: DestroyRef,
private depositManagementService: DepositManagementService,
private log: NotifyLogService,
private userInfoBasedIdGeneratorService: UserInfoBasedIdGeneratorService,
private fetchSourcesService: FetchSourcesService,
) { ) {
super(); super();
} }
ngOnInit() { create() {
this.form = this.createDepositService.form; const { body: sourceCash, ...value } = this.control.value;
this.depositCreated$.subscribe((deposit) => { this.fetchSourcesService.sources$
this.snackBar.open(`Deposit status successfully created`, 'OK', { duration: 3000 }); .pipe(
this.closeWithSuccess(deposit); first(),
this.form.enable(); map(
}); (sources) =>
this.error$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { sources.find((s) => s.id === sourceCash.sourceId).currency_symbolic_code,
this.snackBar.open('An error occurred while deposit create', 'OK'); ),
this.closeWithError(); map(
this.form.enable(); (symbolicCode): DepositParams => ({
}); ...value,
this.pollingError$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { source_id: sourceCash.sourceId,
this.snackBar.open('An error occurred while deposit polling', 'OK'); body: {
this.closeWithError(); amount: sourceCash.amount,
this.form.enable(); currency: { symbolic_code: symbolicCode },
}); },
this.pollingTimeout$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { }),
this.snackBar.open('Polling timeout error', 'OK'); ),
this.closeWithError(); switchMap((params) => this.depositManagementService.Create(params, new Map())),
this.form.enable(); progressTo(this.progress$),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: () => this.closeWithSuccess(),
error: (err) => this.log.error(err),
}); });
} }
createDeposit() { getDefaultValue() {
this.form.disable(); return {
this.createDepositService.createDeposit(); id: this.userInfoBasedIdGeneratorService.getUsernameBasedId(),
source_id: 'STUB',
} as Overwrite<DepositParams, { body: SourceCash }>;
} }
} }

View File

@ -9,9 +9,10 @@ import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { DialogModule } from '@vality/ng-core'; 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 { SourceCashFieldComponent } from '../../../../components/source-cash-field';
import { CurrencySourceFieldComponent } from '../../../shared/components/currency-source-field'; import { CurrencySourceFieldComponent } from '../../../shared/components/currency-source-field';
import { FistfulThriftFormComponent } from '../../../shared/components/fistful-thrift-form';
import { UserInfoBasedIdGeneratorModule } from '../../../shared/services/user-info-based-id-generator/user-info-based-id-generator.module';
import { CreateDepositDialogComponent } from './create-deposit-dialog.component'; import { CreateDepositDialogComponent } from './create-deposit-dialog.component';
@ -28,6 +29,8 @@ import { CreateDepositDialogComponent } from './create-deposit-dialog.component'
UserInfoBasedIdGeneratorModule, UserInfoBasedIdGeneratorModule,
DialogModule, DialogModule,
CurrencySourceFieldComponent, CurrencySourceFieldComponent,
FistfulThriftFormComponent,
SourceCashFieldComponent,
], ],
declarations: [CreateDepositDialogComponent], declarations: [CreateDepositDialogComponent],
}) })

View File

@ -1,117 +0,0 @@
import { Injectable } from '@angular/core';
import { Validators, FormBuilder } from '@angular/forms';
import { DepositParams } from '@vality/fistful-proto/fistful_admin';
import { StatDeposit, StatRequest } from '@vality/fistful-proto/fistful_stat';
import { StatSource } from '@vality/fistful-proto/internal/fistful_stat';
import { getNoTimeZoneIsoString } from '@vality/ng-core';
import { endOfDay, startOfDay } from 'date-fns';
import { EMPTY, forkJoin, merge, Observable, of, Subject } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators';
import { FistfulAdminService } from '@cc/app/api/fistful-admin';
import { FistfulStatisticsService, createDsl } from '@cc/app/api/fistful-stat';
import { progress } from '@cc/app/shared/custom-operators';
import { UserInfoBasedIdGeneratorService } from '@cc/app/shared/services';
import { createDepositStopPollingCondition } from '@cc/app/shared/utils';
import { poll } from '@cc/utils/poll';
import { toMinor } from '@cc/utils/to-minor';
@Injectable()
export class CreateDepositService {
private create$ = new Subject<void>();
private errorSubject$ = new Subject<boolean>();
private pollingErrorSubject$ = new Subject<boolean>();
private pollingTimeoutSubject$ = new Subject<boolean>();
// eslint-disable-next-line @typescript-eslint/member-ordering, @typescript-eslint/no-unsafe-assignment
depositCreated$: Observable<StatDeposit> = this.create$.pipe(
map(() => this.getParams()),
switchMap((params) =>
forkJoin([
of(this.getPollingParams(params)),
this.fistfulAdminService.CreateDeposit(params).pipe(
catchError(() => {
this.errorSubject$.next(true);
return EMPTY;
}),
),
]),
),
switchMap(([pollingParams]) =>
this.fistfulStatisticsService.GetDeposits(pollingParams).pipe(
catchError(() => {
this.pollingErrorSubject$.next(true);
return EMPTY;
}),
map((res) => res.data?.deposits[0]),
poll(createDepositStopPollingCondition),
catchError(() => {
this.pollingTimeoutSubject$.next(true);
return EMPTY;
}),
),
),
);
// eslint-disable-next-line @typescript-eslint/member-ordering
isLoading$ = progress(
this.create$,
merge([this.depositCreated$, this.errorSubject$, this.pollingErrorSubject$]),
);
// eslint-disable-next-line @typescript-eslint/member-ordering
error$ = this.errorSubject$.asObservable();
// eslint-disable-next-line @typescript-eslint/member-ordering
pollingError$ = this.pollingErrorSubject$.asObservable();
// eslint-disable-next-line @typescript-eslint/member-ordering
pollingTimeout$ = this.pollingTimeoutSubject$.asObservable();
// eslint-disable-next-line @typescript-eslint/member-ordering
form = this.fb.group({
destination: ['', Validators.required],
amount: [
null as number,
[Validators.required, Validators.pattern(/^-?\d+([,.]\d{1,2})?$/)],
],
currency: [null as StatSource, Validators.required],
});
constructor(
private fistfulAdminService: FistfulAdminService,
private fistfulStatisticsService: FistfulStatisticsService,
private fb: FormBuilder,
private idGenerator: UserInfoBasedIdGeneratorService,
) {}
createDeposit() {
this.create$.next();
}
private getParams(): DepositParams {
const { destination, amount, currency } = this.form.value;
return {
id: this.idGenerator.getUsernameBasedId(),
source: currency.id,
destination,
body: {
amount: toMinor(amount),
currency: {
symbolic_code: currency.currency_symbolic_code,
},
},
};
}
private getPollingParams(params: DepositParams): StatRequest {
return {
dsl: createDsl({
deposits: {
from_time: getNoTimeZoneIsoString(startOfDay(new Date())),
to_time: getNoTimeZoneIsoString(endOfDay(new Date())),
size: 1,
deposit_id: params.id,
},
}),
};
}
}

View File

@ -1,19 +1,24 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { StatSource } from '@vality/fistful-proto/internal/fistful_stat'; import { StatSource } from '@vality/fistful-proto/internal/fistful_stat';
import { compareDifferentTypes, NotifyLogService } from '@vality/ng-core';
import { Observable, switchMap, of, BehaviorSubject } from 'rxjs'; import { Observable, switchMap, of, BehaviorSubject } from 'rxjs';
import { shareReplay, map, catchError } from 'rxjs/operators'; import { shareReplay, map, catchError } from 'rxjs/operators';
import { FistfulStatisticsService, createDsl } from '@cc/app/api/fistful-stat'; import { FistfulStatisticsService, createDsl } from '@cc/app/api/fistful-stat';
import { progressTo } from '@cc/utils'; import { progressTo } from '@cc/utils';
import { NotificationErrorService } from '../../shared/services/notification-error';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class FetchSourcesService { export class FetchSourcesService {
sources$: Observable<StatSource[]> = this.fetch().pipe( sources$: Observable<StatSource[]> = this.fetch().pipe(
map((s) => s.sort((a, b) => +new Date(a.created_at) - +new Date(b.created_at))), map((s) =>
s.sort(
(a, b) =>
compareDifferentTypes(a.name, b.name) ||
+new Date(a.created_at) - +new Date(b.created_at),
),
),
progressTo(() => this.progress$), progressTo(() => this.progress$),
shareReplay(1), shareReplay(1),
); );
@ -21,7 +26,7 @@ export class FetchSourcesService {
constructor( constructor(
private fistfulStatisticsService: FistfulStatisticsService, private fistfulStatisticsService: FistfulStatisticsService,
private errorService: NotificationErrorService, private log: NotifyLogService,
) {} ) {}
private fetch( private fetch(
@ -35,7 +40,7 @@ export class FetchSourcesService {
}) })
.pipe( .pipe(
catchError((err) => { catchError((err) => {
this.errorService.error(err); this.log.error(err);
return of(null); return of(null);
}), }),
switchMap((res) => switchMap((res) =>

View File

@ -1,7 +1,9 @@
<cc-metadata-form <cc-thrift-editor
[extensions]="extensions" [defaultValue]="defaultValue"
[extensions]="extensions$ | async"
[formControl]="control" [formControl]="control"
[metadata]="metadata$ | async" [metadata]="metadata$ | async"
[namespace]="namespace" [namespace]="namespace ?? defaultNamespace"
[noChangeKind]="noChangeKind"
[type]="type" [type]="type"
></cc-metadata-form> ></cc-thrift-editor>

View File

@ -1,31 +1,29 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core'; import { Component } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';
import { ThriftAstMetadata } from '@vality/fistful-proto'; import { ThriftAstMetadata } from '@vality/fistful-proto';
import { FormControlSuperclass, createControlProviders } from '@vality/ng-core'; import { createControlProviders, getImportValue } from '@vality/ng-core';
import { from, of } from 'rxjs'; import { of } from 'rxjs';
import short from 'short-uuid'; import short from 'short-uuid';
import { MetadataFormModule, isTypeWithAliases, MetadataFormExtension } from '../metadata-form'; import { isTypeWithAliases, MetadataFormExtension } from '../metadata-form';
import { BaseThriftFormSuperclass } from '../thrift-api-crud/thrift-forms/utils/thrift-form-superclass';
import { ThriftEditorModule } from '../thrift-editor';
@Component({ @Component({
standalone: true, standalone: true,
selector: 'cc-fistful-thrift-form', selector: 'cc-fistful-thrift-form',
templateUrl: './fistful-thrift-form.component.html', templateUrl: './fistful-thrift-form.component.html',
providers: createControlProviders(() => FistfulThriftFormComponent), providers: createControlProviders(() => FistfulThriftFormComponent),
imports: [CommonModule, ReactiveFormsModule, MetadataFormModule], imports: [CommonModule, ReactiveFormsModule, ThriftEditorModule],
}) })
export class FistfulThriftFormComponent extends FormControlSuperclass<unknown> { export class FistfulThriftFormComponent extends BaseThriftFormSuperclass {
@Input() namespace: string; metadata$ = getImportValue<ThriftAstMetadata[]>(import('@vality/fistful-proto/metadata.json'));
@Input() type: string; internalExtensions$ = of<MetadataFormExtension[]>([
metadata$ = from(
import('@vality/fistful-proto/metadata.json').then((m) => m.default as ThriftAstMetadata[]),
);
extensions: MetadataFormExtension[] = [
{ {
determinant: (data) => of(isTypeWithAliases(data, 'SourceID', 'fistful')), determinant: (data) => of(isTypeWithAliases(data, 'SourceID', 'fistful')),
extension: () => of({ generate: () => of(short().uuid()), isIdentifier: true }), extension: () => of({ generate: () => of(short().uuid()), isIdentifier: true }),
}, },
]; ]);
defaultNamespace = 'fistful';
} }

View File

@ -1,3 +1,10 @@
<ng-container *ngIf="(extensionResult$ | async)?.template as template; else typeTemplate">
<ng-template
[ngTemplateOutlet]="template"
[ngTemplateOutletContext]="{ control: control }"
></ng-template>
</ng-container>
<ng-template #typeTemplate>
<ng-container [ngSwitch]="(extensionResult$ | async)?.type"> <ng-container [ngSwitch]="(extensionResult$ | async)?.type">
<cc-cash-field <cc-cash-field
*ngSwitchCase="'cash'" *ngSwitchCase="'cash'"
@ -13,7 +20,11 @@
[label]="(extensionResult$ | async)?.label ?? (data.type | fieldLabel: data.field)" [label]="(extensionResult$ | async)?.label ?? (data.type | fieldLabel: data.field)"
style="flex: 1" style="flex: 1"
></v-datetime-field> ></v-datetime-field>
<button *ngIf="!data.isRequired && control.value" mat-icon-button (click)="clear($event)"> <button
*ngIf="!data.isRequired && control.value"
mat-icon-button
(click)="clear($event)"
>
<mat-icon>clear</mat-icon> <mat-icon>clear</mat-icon>
</button> </button>
<button *ngIf="generate$ | async" mat-icon-button (click)="generate($event)"> <button *ngIf="generate$ | async" mat-icon-button (click)="generate($event)">
@ -21,3 +32,4 @@
</button> </button>
</div> </div>
</ng-container> </ng-container>
</ng-template>

View File

@ -1,6 +1,9 @@
<div *ngIf="!(extensionResult$ | async)?.hidden" [ngSwitch]="data?.typeGroup"> <div *ngIf="!(extensionResult$ | async)?.hidden" [ngSwitch]="data?.typeGroup">
<cc-extension-field <cc-extension-field
*ngIf="(extensionResult$ | async)?.type; else defaultFields" *ngIf="
(extensionResult$ | async)?.type || (extensionResult$ | async)?.template;
else defaultFields
"
[data]="data" [data]="data"
[extensions]="extensions" [extensions]="extensions"
[formControl]="control" [formControl]="control"

View File

@ -0,0 +1,3 @@
::ng-deep .cc-metadata-form-hidden {
display: none !important;
}

View File

@ -1,9 +1,11 @@
import { Component, Input, OnChanges } from '@angular/core'; import { Component, Input, OnChanges, HostBinding, OnInit, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Validator } from '@angular/forms'; import { Validator } from '@angular/forms';
import { ThriftAstMetadata } from '@vality/domain-proto'; import { ThriftAstMetadata } from '@vality/domain-proto';
import { createControlProviders, FormControlSuperclass } from '@vality/ng-core'; import { createControlProviders, FormControlSuperclass } from '@vality/ng-core';
import { Field, ValueType } from '@vality/thrift-ts'; import { Field, ValueType } from '@vality/thrift-ts';
import { Observable } from 'rxjs'; import { Observable, BehaviorSubject, defer, switchMap } from 'rxjs';
import { map, distinctUntilChanged, shareReplay } from 'rxjs/operators';
import { MetadataFormData } from './types/metadata-form-data'; import { MetadataFormData } from './types/metadata-form-data';
import { import {
@ -15,11 +17,12 @@ import {
@Component({ @Component({
selector: 'cc-metadata-form', selector: 'cc-metadata-form',
templateUrl: './metadata-form.component.html', templateUrl: './metadata-form.component.html',
styleUrl: `./metadata-form.component.scss`,
providers: createControlProviders(() => MetadataFormComponent), providers: createControlProviders(() => MetadataFormComponent),
}) })
export class MetadataFormComponent<T> export class MetadataFormComponent<T>
extends FormControlSuperclass<T> extends FormControlSuperclass<T>
implements OnChanges, Validator implements OnInit, OnChanges, Validator
{ {
@Input() metadata: ThriftAstMetadata[]; @Input() metadata: ThriftAstMetadata[];
@Input() namespace: string; @Input() namespace: string;
@ -28,8 +31,32 @@ export class MetadataFormComponent<T>
@Input() parent?: MetadataFormData; @Input() parent?: MetadataFormData;
@Input() extensions?: MetadataFormExtension[]; @Input() extensions?: MetadataFormExtension[];
@HostBinding('class.cc-metadata-form-hidden') hidden = false;
data: MetadataFormData; data: MetadataFormData;
extensionResult$: Observable<MetadataFormExtensionResult>; extensionResult$: Observable<MetadataFormExtensionResult> = defer(() => this.updated$).pipe(
switchMap(() => getFirstDeterminedExtensionsResult(this.extensions, this.data)),
shareReplay({ refCount: true, bufferSize: 1 }),
);
private updated$ = new BehaviorSubject<void>(undefined);
constructor(private destroyRef: DestroyRef) {
super();
}
ngOnInit() {
super.ngOnInit();
this.extensionResult$
.pipe(
map((res) => res?.hidden),
distinctUntilChanged(),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((hidden) => {
this.hidden = hidden;
});
}
ngOnChanges() { ngOnChanges() {
if (this.metadata && this.namespace && this.type) { if (this.metadata && this.namespace && this.type) {
@ -41,10 +68,7 @@ export class MetadataFormComponent<T>
this.field, this.field,
this.parent, this.parent,
); );
this.extensionResult$ = getFirstDeterminedExtensionsResult( this.updated$.next(undefined);
this.extensions,
this.data,
);
} catch (err) { } catch (err) {
this.data = undefined; this.data = undefined;
console.warn(err); console.warn(err);

View File

@ -1,3 +1,4 @@
import { TemplateRef } from '@angular/core';
import { ThemePalette } from '@angular/material/core'; import { ThemePalette } from '@angular/material/core';
import { Observable, combineLatest, switchMap, of } from 'rxjs'; import { Observable, combineLatest, switchMap, of } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
@ -22,6 +23,7 @@ export interface MetadataFormExtensionResult {
type?: 'datetime' | 'cash'; type?: 'datetime' | 'cash';
converter?: Converter; converter?: Converter;
hidden?: boolean; hidden?: boolean;
template?: TemplateRef<unknown>;
} }
export interface MetadataFormExtensionOption { export interface MetadataFormExtensionOption {

View File

@ -1,4 +1,4 @@
import { Input, Directive, OnChanges } from '@angular/core'; import { Input, Directive, OnChanges, booleanAttribute } from '@angular/core';
import { ThriftAstMetadata } from '@vality/fistful-proto'; import { ThriftAstMetadata } from '@vality/fistful-proto';
import { FormControlSuperclass, ComponentChanges } from '@vality/ng-core'; import { FormControlSuperclass, ComponentChanges } from '@vality/ng-core';
import { ValueType } from '@vality/thrift-ts'; import { ValueType } from '@vality/thrift-ts';
@ -16,6 +16,7 @@ export abstract class BaseThriftFormSuperclass<T = unknown>
@Input() namespace?: string; @Input() namespace?: string;
@Input() extensions?: MetadataFormExtension[]; @Input() extensions?: MetadataFormExtension[];
@Input() defaultValue?: T; @Input() defaultValue?: T;
@Input({ transform: booleanAttribute }) noChangeKind = false;
protected abstract defaultNamespace: string; protected abstract defaultNamespace: string;
protected abstract metadata$: Observable<ThriftAstMetadata[]>; protected abstract metadata$: Observable<ThriftAstMetadata[]>;

View File

@ -19,6 +19,7 @@
<mat-icon>restart_alt</mat-icon> <mat-icon>restart_alt</mat-icon>
</button> </button>
<button <button
*ngIf="!noChangeKind"
color="primary" color="primary"
mat-icon-button mat-icon-button
matTooltip="Show {{ kind === 'form' ? 'form' : 'JSON' }} editor" matTooltip="Show {{ kind === 'form' ? 'form' : 'JSON' }} editor"

View File

@ -1,4 +1,4 @@
import { Component, Input, Output, EventEmitter } from '@angular/core'; import { Component, Input, Output, EventEmitter, booleanAttribute } from '@angular/core';
import { ValidationErrors, AbstractControl } from '@angular/forms'; import { ValidationErrors, AbstractControl } from '@angular/forms';
import { ThriftAstMetadata } from '@vality/domain-proto'; import { ThriftAstMetadata } from '@vality/domain-proto';
import { import {
@ -35,6 +35,7 @@ export class ThriftEditorComponent<T> extends FormControlSuperclass<T> {
@Input() namespace: string; @Input() namespace: string;
@Input() type: string; @Input() type: string;
@Input() extensions: MetadataFormExtension[]; @Input() extensions: MetadataFormExtension[];
@Input({ transform: booleanAttribute }) noChangeKind = false;
@Output() changeKind = new EventEmitter<EditorKind>(); @Output() changeKind = new EventEmitter<EditorKind>();

View File

@ -0,0 +1 @@
export * from './source-cash-field.component';

View File

@ -0,0 +1,18 @@
<div style="display: flex; gap: 8px">
<mat-form-field style="width: 100%">
<mat-label>{{ label || 'Amount' }}</mat-label>
<span matPrefix>{{ prefix }}&nbsp;</span>
<input
[formControl]="amountControl"
[inputMask]="amountMask$ | async"
[required]="required"
matInput
/>
</mat-form-field>
<v-select-field
[formControl]="sourceControl"
[options]="options$ | async"
[style.width.px]="200"
label="Currency"
></v-select-field>
</div>

View File

@ -0,0 +1,153 @@
import { getCurrencySymbol, CommonModule } from '@angular/common';
import {
Component,
Input,
Inject,
LOCALE_ID,
OnInit,
booleanAttribute,
DestroyRef,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
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 { StatSource } from '@vality/fistful-proto/fistful_stat';
import {
FormComponentSuperclass,
createControlProviders,
getValueChanges,
Option,
SelectFieldModule,
} from '@vality/ng-core';
import isNil from 'lodash-es/isNil';
import { combineLatest } from 'rxjs';
import { map, first, distinctUntilChanged, shareReplay } from 'rxjs/operators';
import { DomainStoreService } from '../../app/api/domain-config';
import { FetchSourcesService } from '../../app/sections/sources';
export interface SourceCash {
amount: number;
sourceId: StatSource['id'];
}
const GROUP_SEPARATOR = ' ';
@Component({
standalone: true,
selector: 'cc-source-cash-field',
templateUrl: './source-cash-field.component.html',
providers: createControlProviders(() => SourceCashFieldComponent),
imports: [
MatFormField,
ReactiveFormsModule,
InputMaskModule,
SelectFieldModule,
CommonModule,
MatInputModule,
],
})
export class SourceCashFieldComponent
extends FormComponentSuperclass<SourceCash>
implements Validator, OnInit
{
@Input() label?: string;
@Input({ transform: booleanAttribute }) required: boolean = false;
amountControl = new FormControl<string>(null);
sourceControl = new FormControl<StatSource>(null);
amountMask$ = getValueChanges(this.sourceControl).pipe(
distinctUntilChanged(),
map(() =>
createMask({
alias: 'numeric',
groupSeparator: GROUP_SEPARATOR,
digits: 0,
digitsOptional: true,
placeholder: '',
}),
),
);
options$ = this.fetchSourcesService.sources$.pipe(
map((sources): Option<StatSource>[] =>
sources.map((s) => ({
label: s.currency_symbolic_code,
description: s.name,
value: s,
})),
),
);
amountSource$ = combineLatest([
getValueChanges(this.amountControl).pipe(
map((amountStr) =>
amountStr ? Number(amountStr.replaceAll(GROUP_SEPARATOR, '')) : null,
),
distinctUntilChanged(),
),
getValueChanges(this.sourceControl),
this.domainStoreService.getObjects('currency'),
]).pipe(
map(([amount, source, currencies]) =>
!isNil(amount) && source
? {
amount,
source,
currency: currencies.find(
(c) => c.data.symbolic_code === source.currency_symbolic_code,
),
}
: null,
),
distinctUntilChanged(),
shareReplay({ refCount: true, bufferSize: 1 }),
);
get currencyCode() {
return this.sourceControl.value?.currency_symbolic_code;
}
get prefix() {
return getCurrencySymbol(this.currencyCode, 'narrow', this._locale);
}
constructor(
@Inject(LOCALE_ID) private _locale: string,
private destroyRef: DestroyRef,
private fetchSourcesService: FetchSourcesService,
private domainStoreService: DomainStoreService,
) {
super();
}
ngOnInit() {
this.amountSource$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((amountSource) => {
this.emitOutgoingValue(
amountSource
? { amount: amountSource.amount, sourceId: amountSource.source.id }
: null,
);
});
}
validate(): ValidationErrors | null {
return !this.amountControl.value || !this.sourceControl.value
? { invalidCash: true }
: null;
}
handleIncomingValue(value: SourceCash) {
this.amountControl.setValue(
typeof value?.amount === 'number' ? String(value.amount) : null,
);
if (value?.sourceId) {
this.options$.pipe(first()).subscribe((opts) => {
this.sourceControl.setValue(opts.find((o) => o.value.id === value.sourceId).value);
});
} else {
this.sourceControl.setValue(null);
}
}
}