diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index aa9501910..c5508abf9 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -18,7 +18,7 @@ jobs: with: release: true - name: Build - run: npm run build -- --define "SENTRY_DSN='${SENTRY_DSN}'" + run: npm run build -- --define "SENTRY_DSN='${SENTRY_DSN}'" --define "SENTRY_RELEASE='${GITHUB_SHA}'" env: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - name: Sentry release @@ -28,6 +28,8 @@ jobs: SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} with: + environment: production + release: ${{ github.sha }} sourcemaps: ./dist/control-center/browser set_commits: skip - name: Remove source maps diff --git a/projects/matez/src/lib/utils/form/form-wrappers/abstract-control-superclass.ts b/projects/matez/src/lib/utils/form/form-wrappers/abstract-control-superclass.ts index 6e048c8f6..c77aae1f3 100644 --- a/projects/matez/src/lib/utils/form/form-wrappers/abstract-control-superclass.ts +++ b/projects/matez/src/lib/utils/form/form-wrappers/abstract-control-superclass.ts @@ -8,6 +8,9 @@ import { WrappedControlSuperclass } from '@s-libs/ng-core'; import { getErrorsTree } from './utils/get-errors-tree'; +/** + * @deprecated Use FormControlValue + */ @Directive() export abstract class AbstractControlSuperclass extends WrappedControlSuperclass diff --git a/projects/matez/src/lib/utils/form/form-wrappers/form-array-superclass.directive.ts b/projects/matez/src/lib/utils/form/form-wrappers/form-array-superclass.directive.ts index 61c6ce535..bbef90a76 100644 --- a/projects/matez/src/lib/utils/form/form-wrappers/form-array-superclass.directive.ts +++ b/projects/matez/src/lib/utils/form/form-wrappers/form-array-superclass.directive.ts @@ -5,6 +5,9 @@ import { hasControls } from '../has-controls'; import { FormGroupSuperclass } from './form-group-superclass.directive'; +/** + * @deprecated Use FormControlValue + */ @Directive() export abstract class FormArraySuperclass< OuterType extends unknown[], diff --git a/projects/matez/src/lib/utils/form/form-wrappers/form-component-superclass.directive.ts b/projects/matez/src/lib/utils/form/form-wrappers/form-component-superclass.directive.ts index fc31dbd3a..6626e3330 100644 --- a/projects/matez/src/lib/utils/form/form-wrappers/form-component-superclass.directive.ts +++ b/projects/matez/src/lib/utils/form/form-wrappers/form-component-superclass.directive.ts @@ -5,6 +5,9 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AbstractControl, ValidationErrors, Validator } from '@angular/forms'; import { FormComponentSuperclass as BaseFormComponentSuperclass } from '@s-libs/ng-core'; +/** + * @deprecated Use FormControlValue + */ @Directive() export abstract class FormComponentSuperclass extends BaseFormComponentSuperclass diff --git a/projects/matez/src/lib/utils/form/form-wrappers/form-control-superclass.directive.ts b/projects/matez/src/lib/utils/form/form-wrappers/form-control-superclass.directive.ts index 172303384..9c0702c57 100644 --- a/projects/matez/src/lib/utils/form/form-wrappers/form-control-superclass.directive.ts +++ b/projects/matez/src/lib/utils/form/form-wrappers/form-control-superclass.directive.ts @@ -3,6 +3,9 @@ import { FormControl } from '@angular/forms'; import { AbstractControlSuperclass } from './abstract-control-superclass'; +/** + * @deprecated Use FormControlValue + */ @Directive() export class FormControlSuperclass< OuterType, diff --git a/projects/matez/src/lib/utils/form/form-wrappers/form-group-superclass.directive.ts b/projects/matez/src/lib/utils/form/form-wrappers/form-group-superclass.directive.ts index 5b778ad70..713d894cc 100644 --- a/projects/matez/src/lib/utils/form/form-wrappers/form-group-superclass.directive.ts +++ b/projects/matez/src/lib/utils/form/form-wrappers/form-group-superclass.directive.ts @@ -6,6 +6,9 @@ import { hasControls } from '../has-controls'; import { AbstractControlSuperclass } from './abstract-control-superclass'; +/** + * @deprecated Use FormControlValue + */ @Directive() export abstract class FormGroupSuperclass extends AbstractControlSuperclass diff --git a/projects/ng-thrift/src/lib/components/thrift-editor/components/extension-field/extension-field.component.ts b/projects/ng-thrift/src/lib/components/thrift-editor/components/extension-field/extension-field.component.ts index 26573582b..d80ef33f5 100644 --- a/projects/ng-thrift/src/lib/components/thrift-editor/components/extension-field/extension-field.component.ts +++ b/projects/ng-thrift/src/lib/components/thrift-editor/components/extension-field/extension-field.component.ts @@ -111,7 +111,9 @@ export class ExtensionFieldComponent this.converter$ .pipe(first(), takeUntilDestroyed(this.destroyRef)) .subscribe((converter) => { - this.control.setValue(converter.outputToInternal(value) as never); + this.control.setValue(converter.outputToInternal(value) as never, { + emitEvent: false, + }); }); } diff --git a/projects/ng-thrift/src/lib/components/thrift-editor/thrift-editor.component.html b/projects/ng-thrift/src/lib/components/thrift-editor/thrift-editor.component.html index c78af1d1c..6bf531030 100644 --- a/projects/ng-thrift/src/lib/components/thrift-editor/thrift-editor.component.html +++ b/projects/ng-thrift/src/lib/components/thrift-editor/thrift-editor.component.html @@ -43,7 +43,7 @@ } @case ('form') { diff --git a/projects/ng-thrift/src/lib/components/thrift-editor/thrift-editor.component.ts b/projects/ng-thrift/src/lib/components/thrift-editor/thrift-editor.component.ts index 94ce2efd1..ddbcc0641 100644 --- a/projects/ng-thrift/src/lib/components/thrift-editor/thrift-editor.component.ts +++ b/projects/ng-thrift/src/lib/components/thrift-editor/thrift-editor.component.ts @@ -3,7 +3,9 @@ import { filter, shareReplay } from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, + Injector, Input, + afterNextRender, booleanAttribute, inject, model, @@ -21,7 +23,9 @@ import { } from '@vality/matez'; import { ValueType } from '@vality/thrift-ts'; +import { ThriftData } from '../../models'; import { ThriftAstMetadata } from '../../types'; +import { fromJson } from '../../utils/thrift-type/from-json'; import { ThriftFormExtension } from './types/thrift-form-extension'; @@ -40,6 +44,7 @@ export enum EditorKind { }) export class ThriftEditorComponent extends FormControlSuperclass { private dialogService = inject(DialogService); + private injector = inject(Injector); readonly kind = model>(EditorKind.Form); @Input() defaultValue?: T; @@ -71,13 +76,29 @@ export class ThriftEditorComponent extends FormControlSuperclass { this.control.updateValueAndValidity(); } + setEditorValue(value: unknown) { + this.control.setValue( + fromJson(value, new ThriftData(this.metadata, this.namespace, this.type)) as T, + ); + } + toggleKind() { this.editorError = null; const kind = this.kind(); switch (kind) { - case EditorKind.Editor: + case EditorKind.Editor: { + const value = this.control.value; this.kind.set(EditorKind.Form); + afterNextRender( + () => { + if (this.kind() === EditorKind.Form) { + this.control.setValue(value); + } + }, + { injector: this.injector }, + ); break; + } case EditorKind.Form: this.kind.set(EditorKind.Editor); break; diff --git a/projects/ng-thrift/src/lib/utils/thrift-type/from-json.spec.ts b/projects/ng-thrift/src/lib/utils/thrift-type/from-json.spec.ts new file mode 100644 index 000000000..e2b72bfd1 --- /dev/null +++ b/projects/ng-thrift/src/lib/utils/thrift-type/from-json.spec.ts @@ -0,0 +1,24 @@ +import { ThriftData } from '../../models'; + +import { fromJson } from './from-json'; + +describe('fromJson', () => { + it('restores maps and nested sets from their JSON representation', () => { + const data = new ThriftData([], 'test', { + name: 'map', + keyType: 'string', + valueType: { name: 'set', valueType: 'i64' }, + }); + + expect(fromJson([['RUB', [1, 2]]], data)).toEqual(new Map([['RUB', new Set([1, 2])]])); + }); + + it('keeps thrift lists as arrays', () => { + const data = new ThriftData([], 'test', { + name: 'list', + valueType: 'i64', + }); + + expect(fromJson([1, 2], data)).toEqual([1, 2]); + }); +}); diff --git a/projects/ng-thrift/src/lib/utils/thrift-type/from-json.ts b/projects/ng-thrift/src/lib/utils/thrift-type/from-json.ts new file mode 100644 index 000000000..d473eae15 --- /dev/null +++ b/projects/ng-thrift/src/lib/utils/thrift-type/from-json.ts @@ -0,0 +1,62 @@ +import { Field, ListType, MapType, SetType } from '@vality/thrift-ts'; + +import { ThriftData } from '../../models'; + +export function fromJson(value: unknown, data: ThriftData): unknown { + if (value == null) { + return value; + } + + const trueData = data.trueTypeNode.data; + if (trueData.typeGroup === 'complex') { + switch ((trueData.type as ListType | MapType | SetType).name) { + case 'list': + return Array.from(value as unknown[]).map((item) => + fromJson( + item, + trueData.create({ type: (trueData.type as ListType).valueType }), + ), + ); + case 'set': + return new Set( + Array.from(value as Set | unknown[]).map((item) => + fromJson( + item, + trueData.create({ type: (trueData.type as SetType).valueType }), + ), + ), + ); + case 'map': { + const mapType = trueData.type as MapType; + const entries = + value instanceof Map + ? Array.from(value.entries()) + : (value as [unknown, unknown][]); + return new Map( + entries.map(([key, item]): [unknown, unknown] => [ + fromJson(key, trueData.create({ type: mapType.keyType })), + fromJson(item, trueData.create({ type: mapType.valueType })), + ]), + ); + } + } + } + + if ( + (trueData.objectType === 'struct' || + trueData.objectType === 'union' || + trueData.objectType === 'exception') && + typeof value === 'object' && + !Array.isArray(value) + ) { + const fields = (trueData.ast ?? []) as Field[]; + return Object.fromEntries( + Object.entries(value).map(([name, item]) => { + const field = fields.find((candidate) => candidate.name === name); + return [name, field ? fromJson(item, trueData.create({ field })) : item]; + }), + ); + } + + return value; +} diff --git a/src/components/account-field/account-field.component.html b/src/components/account-field/account-field.component.html index 0b52e238a..2827fe59a 100644 --- a/src/components/account-field/account-field.component.html +++ b/src/components/account-field/account-field.component.html @@ -1,6 +1,5 @@ -@let noCurrencyAccount = noCurrencyAccount$ | async; @let inProgress = !!(progress$ | async); -@let generateDisabled = !noCurrencyAccount || inProgress || control.value?.length !== 3; +@let generateDisabled = !hasMissingAccounts() || inProgress || currency()?.length !== 3;
diff --git a/src/components/account-field/account-field.component.ts b/src/components/account-field/account-field.component.ts index bbfcb4d56..d8cd1b060 100644 --- a/src/components/account-field/account-field.component.ts +++ b/src/components/account-field/account-field.component.ts @@ -1,33 +1,31 @@ import { BehaviorSubject, combineLatest, of } from 'rxjs'; -import { catchError, distinctUntilChanged, map, shareReplay, tap } from 'rxjs/operators'; +import { catchError, map } from 'rxjs/operators'; import { CommonModule } from '@angular/common'; import { - ChangeDetectionStrategy, Component, DestroyRef, - OnInit, computed, + effect, inject, input, - signal, + model, + output, + untracked, } from '@angular/core'; -import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { FormValueControl, transformedValue } from '@angular/forms/signals'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { AutocompleteFieldModule, - FormComponentSuperclass, NotifyLogService, Option, compareDifferentTypes, - createControlProviders, - getValueChanges, progressTo, - switchCombineWith, } from '@vality/matez'; import { CurrenciesStoreService } from '~/api/domain-config'; @@ -38,6 +36,8 @@ export interface CurrencyAccount { accounts: number[]; } +const CURRENCY_CODE_PATTERN = /^[A-Z]{3}$/; + @Component({ selector: 'cc-account-field', imports: [ @@ -49,25 +49,42 @@ export interface CurrencyAccount { MatTooltipModule, ], templateUrl: './account-field.component.html', - changeDetection: ChangeDetectionStrategy.Eager, - providers: createControlProviders(() => AccountFieldComponent), }) -export class AccountFieldComponent - extends FormComponentSuperclass - implements OnInit -{ +export class AccountFieldComponent implements FormValueControl { private currenciesStoreService = inject(CurrenciesStoreService); private accountManagementService = inject(ThriftAccountManagementService); private dr = inject(DestroyRef); private log = inject(NotifyLogService); - control = new FormControl(null, { nonNullable: true }); - label = input('Account currency'); accountsNumber = input(1); optionalAccountsNumber = input(0); - - currencyAccounts = signal([]); + disabled = model(false); + touch = output(); + + value = model(null); + currency = transformedValue(this.value, { + parse: (currency) => { + const currentValue = this.value(); + if (!CURRENCY_CODE_PATTERN.test(currency)) { + return { + error: { + kind: 'currencyFormat', + message: 'Currency must contain exactly 3 English letters', + }, + }; + } + return { + value: { + currency, + accounts: currency === currentValue?.currency ? currentValue.accounts : [], + }, + }; + }, + format: (value) => value?.currency, + }); + control = new FormControl(null, { nonNullable: true }); + currencyAccounts = computed(() => this.value()?.accounts ?? []); progress$ = new BehaviorSubject(0); hint = computed(() => this.currencyAccounts().length @@ -89,41 +106,62 @@ export class AccountFieldComponent ), ); - noCurrencyAccount$ = combineLatest([ - toObservable(this.currencyAccounts), - toObservable(this.accountsNumber), - toObservable(this.optionalAccountsNumber), - ]).pipe( - map( - ([accounts, accountsNumber, optionalAccountsNumber]) => - accounts.length < accountsNumber + optionalAccountsNumber, - ), - distinctUntilChanged(), - shareReplay({ refCount: true, bufferSize: 1 }), + hasMissingAccounts = computed( + () => + this.currencyAccounts().length < this.accountsNumber() + this.optionalAccountsNumber(), ); - override ngOnInit() { - super.ngOnInit(); - getValueChanges(this.control) - .pipe( - distinctUntilChanged(), - tap((currency) => { - this.setAccounts(currency); - }), - switchCombineWith((currency) => [this.createAccounts(currency)]), - takeUntilDestroyed(this.dr), - ) - .subscribe(([currency, accounts]) => { - this.setAccounts(currency, accounts); + constructor() { + this.control.valueChanges.pipe(takeUntilDestroyed(this.dr)).subscribe((currency) => { + const normalizedCurrency = currency?.toUpperCase(); + if (currency !== normalizedCurrency) { + this.control.setValue(normalizedCurrency, { emitEvent: false }); + } + this.currency.set(normalizedCurrency); + }); + + effect(() => { + const currency = this.currency(); + const disabled = this.disabled(); + untracked(() => { + if (currency !== this.control.value) { + this.control.setValue(currency, { emitEvent: false }); + } + if (disabled !== this.control.disabled) { + if (disabled) { + this.control.disable({ emitEvent: false }); + } else { + this.control.enable({ emitEvent: false }); + } + } }); + }); + + effect((onCleanup) => { + const currency = this.currency(); + const currentValue = untracked(this.value); + if ( + !this.control.dirty || + !CURRENCY_CODE_PATTERN.test(currency) || + currentValue?.currency !== currency || + currentValue.accounts.length + ) { + return; + } + const subscription = untracked(() => this.createAccounts(currency)).subscribe( + (accounts) => { + this.setAccounts(currency, accounts); + }, + ); + onCleanup(() => subscription.unsubscribe()); + }); } - override handleIncomingValue(value: CurrencyAccount) { - this.currencyAccounts.set(value?.accounts || []); - this.control.setValue(value?.currency, { emitEvent: false }); + markAsTouched() { + this.touch.emit(); } - generate(currency = this.control.value) { + generate(currency = this.currency()) { this.createAccounts(currency) .pipe(takeUntilDestroyed(this.dr)) .subscribe((accounts) => { @@ -132,8 +170,7 @@ export class AccountFieldComponent } private setAccounts(currency: string, accounts: number[] = []) { - this.currencyAccounts.set(accounts); - this.emitOutgoingValue({ currency, accounts }); + this.value.set({ currency, accounts }); } private createAccounts(currency: string) { diff --git a/src/components/shop-merchant-field/shop-merchant-field.component.html b/src/components/shop-merchant-field/shop-merchant-field.component.html index 418445720..6e6397819 100644 --- a/src/components/shop-merchant-field/shop-merchant-field.component.html +++ b/src/components/shop-merchant-field/shop-merchant-field.component.html @@ -1,4 +1,4 @@
- +
diff --git a/src/globals.d.ts b/src/globals.d.ts index 8bf64a771..749d3ab98 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -1 +1,2 @@ declare const SENTRY_DSN: string; +declare const SENTRY_RELEASE: string; diff --git a/src/main.ts b/src/main.ts index 9ee394a23..4ef9c116b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,7 @@ import { appConfig } from './app/app.config'; if (SENTRY_DSN) { Sentry.init({ dsn: SENTRY_DSN, + release: typeof SENTRY_RELEASE === 'undefined' ? undefined : SENTRY_RELEASE, environment: isDevMode() ? 'development' : 'production', integrations: [ Sentry.breadcrumbsIntegration({