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

View File

@ -30,7 +30,7 @@
"@ngneat/input-mask": "6.0.0",
"@vality/deanonimus-proto": "2.0.1-2a02d87.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/magista-proto": "2.0.2-28d11b9.0",
"@vality/ng-core": "^17.1.1-pr-57-4da820a.0",

View File

@ -4,9 +4,10 @@ import {
ThriftAstMetadata,
deposit_Management,
} 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 { 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 { KeycloakTokenInfoService, toWachterHeaders } from '@cc/app/shared/services';
@ -15,7 +16,7 @@ import { environment } from '@cc/environments/environment';
import { ConfigService } from '../../core/config.service';
@Injectable({ providedIn: 'root' })
export class ManagementService {
export class DepositManagementService {
private client$: Observable<deposit_ManagementCodegenClient>;
constructor(
@ -51,4 +52,9 @@ export class ManagementService {
CreateRevert(id: DepositID, params: RevertParams): Observable<RevertState> {
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 { BehaviorSubject } from 'rxjs';
import { ManagementService } from '@cc/app/api/deposit';
import { DepositManagementService } from '@cc/app/api/deposit';
import { UserInfoBasedIdGeneratorService } from '../../../../shared/services';
@ -31,7 +31,7 @@ export class CreateRevertDialogComponent extends DialogSuperclass<
constructor(
private fb: NonNullableFormBuilder,
private depositManagementService: ManagementService,
private depositManagementService: DepositManagementService,
private idGenerator: UserInfoBasedIdGeneratorService,
private log: NotifyLogService,
private destroyRef: DestroyRef,

View File

@ -1,33 +1,24 @@
<v-dialog [progress]="isLoading$ | async" title="Create deposit">
<form *ngIf="form" [formGroup]="form" style="display: flex; flex-direction: column; gap: 16px">
<mat-form-field>
<input formControlName="destination" matInput placeholder="Destination" required />
</mat-form-field>
<div style="display: flex; gap: 24px">
<mat-form-field style="flex: 1">
<input
formControlName="amount"
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 [progress]="progress$ | async" title="Create deposit">
<cc-fistful-thrift-form
[defaultValue]="getDefaultValue()"
[extensions]="extensions"
[formControl]="control"
namespace="deposit"
noChangeKind
type="DepositParams"
></cc-fistful-thrift-form>
<v-dialog-actions>
<button
[disabled]="(isLoading$ | async) || form.invalid"
[disabled]="(progress$ | async) || control.invalid"
color="primary"
mat-button
(click)="createDeposit()"
(click)="create()"
>
Create
</button>
</v-dialog-actions>
</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 { UntypedFormGroup } from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar';
import { StatDeposit } from '@vality/fistful-proto/fistful_stat';
import { DialogSuperclass } from '@vality/ng-core';
import { FormControl, Validators } from '@angular/forms';
import { DepositParams } from '@vality/fistful-proto/deposit';
import { DialogSuperclass, NotifyLogService, progressTo } 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({
templateUrl: 'create-deposit-dialog.component.html',
styleUrls: ['create-deposit-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [CreateDepositService],
})
export class CreateDepositDialogComponent
extends DialogSuperclass<CreateDepositDialogComponent, void, StatDeposit>
implements OnInit
{
form: UntypedFormGroup;
depositCreated$ = this.createDepositService.depositCreated$;
isLoading$ = this.createDepositService.isLoading$;
error$ = this.createDepositService.error$;
pollingError$ = this.createDepositService.pollingError$;
pollingTimeout$ = this.createDepositService.pollingTimeout$;
export class CreateDepositDialogComponent extends DialogSuperclass<CreateDepositDialogComponent> {
@ViewChild('sourceCashTemplate') sourceCashTemplate: TemplateRef<unknown>;
control = new FormControl(this.getDefaultValue(), [Validators.required]);
progress$ = new BehaviorSubject(0);
extensions: MetadataFormExtension[] = [
{
determinant: (data) =>
of(
isTypeWithAliases(data, 'ContextSet', 'context') ||
isTypeWithAliases(data, 'DepositID', 'deposit') ||
isTypeWithAliases(data, 'SourceID', 'deposit'),
),
extension: () => of({ hidden: true }),
},
{
determinant: (data) => of(isTypeWithAliases(data, 'Cash', 'base')),
extension: () => of({ template: this.sourceCashTemplate }),
},
];
constructor(
private createDepositService: CreateDepositService,
private snackBar: MatSnackBar,
private destroyRef: DestroyRef,
private depositManagementService: DepositManagementService,
private log: NotifyLogService,
private userInfoBasedIdGeneratorService: UserInfoBasedIdGeneratorService,
private fetchSourcesService: FetchSourcesService,
) {
super();
}
ngOnInit() {
this.form = this.createDepositService.form;
this.depositCreated$.subscribe((deposit) => {
this.snackBar.open(`Deposit status successfully created`, 'OK', { duration: 3000 });
this.closeWithSuccess(deposit);
this.form.enable();
});
this.error$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.snackBar.open('An error occurred while deposit create', 'OK');
this.closeWithError();
this.form.enable();
});
this.pollingError$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.snackBar.open('An error occurred while deposit polling', 'OK');
this.closeWithError();
this.form.enable();
});
this.pollingTimeout$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.snackBar.open('Polling timeout error', 'OK');
this.closeWithError();
this.form.enable();
});
create() {
const { body: sourceCash, ...value } = this.control.value;
this.fetchSourcesService.sources$
.pipe(
first(),
map(
(sources) =>
sources.find((s) => s.id === sourceCash.sourceId).currency_symbolic_code,
),
map(
(symbolicCode): DepositParams => ({
...value,
source_id: sourceCash.sourceId,
body: {
amount: sourceCash.amount,
currency: { symbolic_code: symbolicCode },
},
}),
),
switchMap((params) => this.depositManagementService.Create(params, new Map())),
progressTo(this.progress$),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: () => this.closeWithSuccess(),
error: (err) => this.log.error(err),
});
}
createDeposit() {
this.form.disable();
this.createDepositService.createDeposit();
getDefaultValue() {
return {
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 { 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 { 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';
@ -28,6 +29,8 @@ import { CreateDepositDialogComponent } from './create-deposit-dialog.component'
UserInfoBasedIdGeneratorModule,
DialogModule,
CurrencySourceFieldComponent,
FistfulThriftFormComponent,
SourceCashFieldComponent,
],
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 { StatSource } from '@vality/fistful-proto/internal/fistful_stat';
import { compareDifferentTypes, NotifyLogService } from '@vality/ng-core';
import { Observable, switchMap, of, BehaviorSubject } from 'rxjs';
import { shareReplay, map, catchError } from 'rxjs/operators';
import { FistfulStatisticsService, createDsl } from '@cc/app/api/fistful-stat';
import { progressTo } from '@cc/utils';
import { NotificationErrorService } from '../../shared/services/notification-error';
@Injectable({
providedIn: 'root',
})
export class FetchSourcesService {
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$),
shareReplay(1),
);
@ -21,7 +26,7 @@ export class FetchSourcesService {
constructor(
private fistfulStatisticsService: FistfulStatisticsService,
private errorService: NotificationErrorService,
private log: NotifyLogService,
) {}
private fetch(
@ -35,7 +40,7 @@ export class FetchSourcesService {
})
.pipe(
catchError((err) => {
this.errorService.error(err);
this.log.error(err);
return of(null);
}),
switchMap((res) =>

View File

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

View File

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

View File

@ -1,23 +1,35 @@
<ng-container [ngSwitch]="(extensionResult$ | async)?.type">
<cc-cash-field
*ngSwitchCase="'cash'"
[formControl]="control"
[required]="data.isRequired"
label="{{ data.type | fieldLabel: data.field }} (amount)"
minor
></cc-cash-field>
<div *ngSwitchCase="'datetime'" style="display: flex; gap: 4px">
<v-datetime-field
[formControl]="control"
[hint]="aliases"
[label]="(extensionResult$ | async)?.label ?? (data.type | fieldLabel: data.field)"
style="flex: 1"
></v-datetime-field>
<button *ngIf="!data.isRequired && control.value" mat-icon-button (click)="clear($event)">
<mat-icon>clear</mat-icon>
</button>
<button *ngIf="generate$ | async" mat-icon-button (click)="generate($event)">
<mat-icon>loop</mat-icon>
</button>
</div>
<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">
<cc-cash-field
*ngSwitchCase="'cash'"
[formControl]="control"
[required]="data.isRequired"
label="{{ data.type | fieldLabel: data.field }} (amount)"
minor
></cc-cash-field>
<div *ngSwitchCase="'datetime'" style="display: flex; gap: 4px">
<v-datetime-field
[formControl]="control"
[hint]="aliases"
[label]="(extensionResult$ | async)?.label ?? (data.type | fieldLabel: data.field)"
style="flex: 1"
></v-datetime-field>
<button
*ngIf="!data.isRequired && control.value"
mat-icon-button
(click)="clear($event)"
>
<mat-icon>clear</mat-icon>
</button>
<button *ngIf="generate$ | async" mat-icon-button (click)="generate($event)">
<mat-icon>loop</mat-icon>
</button>
</div>
</ng-container>
</ng-template>

View File

@ -1,6 +1,9 @@
<div *ngIf="!(extensionResult$ | async)?.hidden" [ngSwitch]="data?.typeGroup">
<cc-extension-field
*ngIf="(extensionResult$ | async)?.type; else defaultFields"
*ngIf="
(extensionResult$ | async)?.type || (extensionResult$ | async)?.template;
else defaultFields
"
[data]="data"
[extensions]="extensions"
[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 { ThriftAstMetadata } from '@vality/domain-proto';
import { createControlProviders, FormControlSuperclass } from '@vality/ng-core';
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 {
@ -15,11 +17,12 @@ import {
@Component({
selector: 'cc-metadata-form',
templateUrl: './metadata-form.component.html',
styleUrl: `./metadata-form.component.scss`,
providers: createControlProviders(() => MetadataFormComponent),
})
export class MetadataFormComponent<T>
extends FormControlSuperclass<T>
implements OnChanges, Validator
implements OnInit, OnChanges, Validator
{
@Input() metadata: ThriftAstMetadata[];
@Input() namespace: string;
@ -28,8 +31,32 @@ export class MetadataFormComponent<T>
@Input() parent?: MetadataFormData;
@Input() extensions?: MetadataFormExtension[];
@HostBinding('class.cc-metadata-form-hidden') hidden = false;
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() {
if (this.metadata && this.namespace && this.type) {
@ -41,10 +68,7 @@ export class MetadataFormComponent<T>
this.field,
this.parent,
);
this.extensionResult$ = getFirstDeterminedExtensionsResult(
this.extensions,
this.data,
);
this.updated$.next(undefined);
} catch (err) {
this.data = undefined;
console.warn(err);

View File

@ -1,3 +1,4 @@
import { TemplateRef } from '@angular/core';
import { ThemePalette } from '@angular/material/core';
import { Observable, combineLatest, switchMap, of } from 'rxjs';
import { map } from 'rxjs/operators';
@ -22,6 +23,7 @@ export interface MetadataFormExtensionResult {
type?: 'datetime' | 'cash';
converter?: Converter;
hidden?: boolean;
template?: TemplateRef<unknown>;
}
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 { FormControlSuperclass, ComponentChanges } from '@vality/ng-core';
import { ValueType } from '@vality/thrift-ts';
@ -16,6 +16,7 @@ export abstract class BaseThriftFormSuperclass<T = unknown>
@Input() namespace?: string;
@Input() extensions?: MetadataFormExtension[];
@Input() defaultValue?: T;
@Input({ transform: booleanAttribute }) noChangeKind = false;
protected abstract defaultNamespace: string;
protected abstract metadata$: Observable<ThriftAstMetadata[]>;

View File

@ -19,6 +19,7 @@
<mat-icon>restart_alt</mat-icon>
</button>
<button
*ngIf="!noChangeKind"
color="primary"
mat-icon-button
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 { ThriftAstMetadata } from '@vality/domain-proto';
import {
@ -35,6 +35,7 @@ export class ThriftEditorComponent<T> extends FormControlSuperclass<T> {
@Input() namespace: string;
@Input() type: string;
@Input() extensions: MetadataFormExtension[];
@Input({ transform: booleanAttribute }) noChangeKind = false;
@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);
}
}
}