diff --git a/angular.json b/angular.json index 0aea7868..10b88c00 100644 --- a/angular.json +++ b/angular.json @@ -31,6 +31,7 @@ "thrift-ts", "buffer", "humanize-duration", + "node-int64", "@vality/deanonimus-proto", "@vality/domain-proto", "@vality/dominant-cache-proto", diff --git a/src/app/api/utils/thrift-instance/create-thrift-instance.ts b/src/app/api/utils/thrift-instance/create-thrift-instance.ts index 0b67db4f..7e66fa46 100644 --- a/src/app/api/utils/thrift-instance/create-thrift-instance.ts +++ b/src/app/api/utils/thrift-instance/create-thrift-instance.ts @@ -6,8 +6,7 @@ import { isPrimitiveType, isThriftObject, parseNamespaceType, - StructureType, - STRUCTURE_TYPES, + parseNamespaceObjectType, } from './namespace-type'; import { ThriftAstMetadata, ThriftInstanceContext } from './types'; @@ -52,26 +51,17 @@ export function createThriftInstance( return value; } } - const namespaceMeta = metadata.find((m) => m.name === namespace); - const structureType = (Object.keys(namespaceMeta.ast) as StructureType[]).find( - (t) => namespaceMeta.ast[t][type] - ); - if (!structureType || !STRUCTURE_TYPES.includes(structureType)) { - throw new Error('Unknown thrift structure type'); - } - switch (structureType) { + const { namespaceMetadata, objectType } = parseNamespaceObjectType(metadata, namespace, type); + switch (objectType) { case 'enum': return value; case 'exception': throw new Error('Unsupported structure type: exception'); default: { - const typeMeta = namespaceMeta.ast[structureType][type]; + const typeMeta = namespaceMetadata.ast[objectType][type]; try { - if (structureType === 'typedef') { - type TypedefType = { - type: ValueType; - }; - const typedefMeta = (typeMeta as TypedefType).type; + if (objectType === 'typedef') { + const typedefMeta = (typeMeta as { type: ValueType }).type; return internalCreateThriftInstance(typedefMeta, value); } const instance = new instanceContext[namespace][type](); @@ -84,7 +74,7 @@ export function createThriftInstance( } catch (error) { console.error( 'Thrift structure', - structureType, + objectType, 'creation error:', namespace, type, diff --git a/src/app/api/utils/thrift-instance/namespace-type.ts b/src/app/api/utils/thrift-instance/namespace-type.ts index 475bbc6f..33bbe3fb 100644 --- a/src/app/api/utils/thrift-instance/namespace-type.ts +++ b/src/app/api/utils/thrift-instance/namespace-type.ts @@ -1,5 +1,7 @@ import type { ListType, MapType, SetType, ThriftType, ValueType } from '@vality/thrift-ts'; +import { ThriftAstMetadata } from '@cc/app/api/utils'; + export const PRIMITIVE_TYPES = [ 'int', 'bool', @@ -21,15 +23,43 @@ export function isComplexType(type: ValueType): type is SetType | ListType | Map } export function isPrimitiveType(type: ValueType): type is ThriftType { - return PRIMITIVE_TYPES.includes(type as any); + return PRIMITIVE_TYPES.includes(type as never); } export const STRUCTURE_TYPES = ['typedef', 'struct', 'union', 'exception', 'enum'] as const; export type StructureType = typeof STRUCTURE_TYPES[number]; -export function parseNamespaceType(type: ValueType, currentNamespace?: string) { - if (!isComplexType(type) && !isPrimitiveType(type) && type.includes('.')) { - [currentNamespace, type] = type.split('.'); - } - return { namespace: currentNamespace, type }; +export interface NamespaceObjectType { + namespaceMetadata: ThriftAstMetadata; + objectType: StructureType; +} + +export function parseNamespaceObjectType( + metadata: ThriftAstMetadata[], + namespace: string, + type: string +): NamespaceObjectType { + const namespaceMetadata = metadata.find((m) => m.name === namespace); + const objectType = (Object.keys(namespaceMetadata.ast) as StructureType[]).find( + (t) => namespaceMetadata.ast[t][type] + ); + if (!objectType || !STRUCTURE_TYPES.includes(objectType)) { + throw new Error(`Unknown thrift structure type: ${objectType}`); + } + return { namespaceMetadata, objectType }; +} + +export interface NamespaceType { + namespace: string; + type: T; +} + +export function parseNamespaceType( + type: T, + namespace?: string +): NamespaceType { + if (!isPrimitiveType(type) && !isComplexType(type) && type.includes('.')) { + [namespace, type as unknown] = type.split('.'); + } + return { namespace, type }; } diff --git a/src/app/api/utils/thrift-instance/thrift-instance-to-object.ts b/src/app/api/utils/thrift-instance/thrift-instance-to-object.ts index d30c44fa..2a12970f 100644 --- a/src/app/api/utils/thrift-instance/thrift-instance-to-object.ts +++ b/src/app/api/utils/thrift-instance/thrift-instance-to-object.ts @@ -5,8 +5,7 @@ import { isComplexType, isPrimitiveType, parseNamespaceType, - StructureType, - STRUCTURE_TYPES, + parseNamespaceObjectType, } from './namespace-type'; import { ThriftAstMetadata } from './types'; @@ -52,15 +51,9 @@ export function thriftInstanceToObject( return value; } } - const namespaceMeta = metadata.find((m) => m.name === namespace); - const structureType = (Object.keys(namespaceMeta.ast) as StructureType[]).find( - (t) => namespaceMeta.ast[t][type] - ); - if (!structureType || !STRUCTURE_TYPES.includes(structureType)) { - throw new Error(`Unknown thrift structure type: ${structureType}`); - } - const typeMeta = namespaceMeta.ast[structureType][type]; - switch (structureType) { + const { namespaceMetadata, objectType } = parseNamespaceObjectType(metadata, namespace, type); + const typeMeta = namespaceMetadata.ast[objectType][type]; + switch (objectType) { case 'exception': throw new Error('Unsupported structure type: exception'); case 'typedef': { diff --git a/src/app/sections/claim/claim.component.html b/src/app/sections/claim/claim.component.html index 848682f2..a7493c68 100644 --- a/src/app/sections/claim/claim.component.html +++ b/src/app/sections/claim/claim.component.html @@ -93,5 +93,14 @@ + + + + + + + diff --git a/src/app/sections/claim/claim.component.ts b/src/app/sections/claim/claim.component.ts index 8991d75b..83fdb438 100644 --- a/src/app/sections/claim/claim.component.ts +++ b/src/app/sections/claim/claim.component.ts @@ -1,15 +1,29 @@ -import { Component } from '@angular/core'; +import { Component, Inject } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute } from '@angular/router'; -import { Observable, switchMap, EMPTY, BehaviorSubject, merge } from 'rxjs'; -import { shareReplay, catchError } from 'rxjs/operators'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { + Observable, + switchMap, + EMPTY, + BehaviorSubject, + merge, + combineLatest, + Subject, + defer, +} from 'rxjs'; +import { shareReplay, catchError, map, first } from 'rxjs/operators'; import { ClaimManagementService } from '@cc/app/api/claim-management'; import { PartyManagementWithUserService } from '@cc/app/api/payment-processing'; import { NotificationService } from '@cc/app/shared/services/notification'; +import { DIALOG_CONFIG, DialogConfig } from '@cc/app/tokens'; import { inProgressFrom, progressTo } from '@cc/utils'; +import { AddModificationDialogComponent } from './components/add-modification-dialog/add-modification-dialog.component'; import { CLAIM_STATUS_COLOR } from './types/claim-status-color'; +@UntilDestroy() @Component({ selector: 'cc-claim', templateUrl: './claim.component.html', @@ -29,8 +43,12 @@ export class ClaimComponent { ), shareReplay({ refCount: true, bufferSize: 1 }) ); - claim$ = (this.route.params as Observable>).pipe( - switchMap(({ claimID, partyID }) => + claim$ = merge( + this.route.params, + defer(() => this.loadClaim$) + ).pipe( + map(() => this.route.snapshot.params as Record), + switchMap(({ partyID, claimID }) => this.claimManagementService.GetClaim(partyID, Number(claimID)).pipe( progressTo(this.progress$), catchError((err) => { @@ -46,11 +64,33 @@ export class ClaimComponent { statusColor = CLAIM_STATUS_COLOR; private progress$ = new BehaviorSubject(0); + private loadClaim$ = new Subject(); constructor( private route: ActivatedRoute, private claimManagementService: ClaimManagementService, private partyManagementWithUserService: PartyManagementWithUserService, - private notificationService: NotificationService + private notificationService: NotificationService, + private dialog: MatDialog, + @Inject(DIALOG_CONFIG) private dialogConfig: DialogConfig ) {} + + addModification() { + combineLatest([this.party$, this.claim$]) + .pipe( + first(), + switchMap(([party, claim]) => + this.dialog + .open(AddModificationDialogComponent, { + ...this.dialogConfig.large, + data: { party, claim }, + }) + .afterClosed() + ), + untilDestroyed(this) + ) + .subscribe((result) => { + if (result === 'success') this.loadClaim$.next(); + }); + } } diff --git a/src/app/sections/claim/claim.module.ts b/src/app/sections/claim/claim.module.ts index cccbbdd1..58ece2e3 100644 --- a/src/app/sections/claim/claim.module.ts +++ b/src/app/sections/claim/claim.module.ts @@ -1,15 +1,19 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { FlexLayoutModule } from '@angular/flex-layout'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatDividerModule } from '@angular/material/divider'; import { MatExpansionModule } from '@angular/material/expansion'; +import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSelectModule } from '@angular/material/select'; import { RouterModule } from '@angular/router'; -import { ShopModificationTimelineItemComponent } from '@cc/app/sections/claim/components/shop-modification-timeline-item/shop-modification-timeline-item.component'; -import { StatusModule } from '@cc/app/shared/components'; +import { MetadataFormModule, StatusModule } from '@cc/app/shared/components'; import { JsonViewerModule } from '@cc/app/shared/components/json-viewer/json-viewer.module'; import { ThriftPipesModule } from '@cc/app/shared/pipes'; import { TimelineModule } from '@cc/components/timeline'; @@ -17,8 +21,10 @@ import { TimelineModule } from '@cc/components/timeline'; import { TimelineComponentsModule } from '../party-claim/changeset/timeline-components'; import { ClaimRoutingModule } from './claim-routing.module'; import { ClaimComponent } from './claim.component'; +import { AddModificationDialogComponent } from './components/add-modification-dialog/add-modification-dialog.component'; import { CommentModificationTimelineItemComponent } from './components/comment-modification-timeline-item/comment-modification-timeline-item.component'; import { ModificationUnitTimelineItemComponent } from './components/modification-unit-timeline-item/modification-unit-timeline-item.component'; +import { ShopModificationTimelineItemComponent } from './components/shop-modification-timeline-item/shop-modification-timeline-item.component'; import { StatusModificationTimelineItemComponent } from './components/status-modification-timeline-item/status-modification-timeline-item.component'; @NgModule({ @@ -28,6 +34,7 @@ import { StatusModificationTimelineItemComponent } from './components/status-mod StatusModificationTimelineItemComponent, CommentModificationTimelineItemComponent, ShopModificationTimelineItemComponent, + AddModificationDialogComponent, ], imports: [ CommonModule, @@ -44,6 +51,12 @@ import { StatusModificationTimelineItemComponent } from './components/status-mod StatusModule, MatDividerModule, MatProgressSpinnerModule, + MatButtonModule, + ReactiveFormsModule, + MatFormFieldModule, + MatSelectModule, + MatInputModule, + MetadataFormModule, ], }) export class ClaimModule {} diff --git a/src/app/sections/claim/components/add-modification-dialog/add-modification-dialog.component.html b/src/app/sections/claim/components/add-modification-dialog/add-modification-dialog.component.html new file mode 100644 index 00000000..7edf963a --- /dev/null +++ b/src/app/sections/claim/components/add-modification-dialog/add-modification-dialog.component.html @@ -0,0 +1,24 @@ +
+
Add modification unit
+ + + +
+ + +
+
diff --git a/src/app/sections/claim/components/add-modification-dialog/add-modification-dialog.component.ts b/src/app/sections/claim/components/add-modification-dialog/add-modification-dialog.component.ts new file mode 100644 index 00000000..b80f6218 --- /dev/null +++ b/src/app/sections/claim/components/add-modification-dialog/add-modification-dialog.component.ts @@ -0,0 +1,150 @@ +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { FormBuilder } from '@ngneat/reactive-forms'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { Claim, Modification } from '@vality/domain-proto/lib/claim_management'; +import { Party } from '@vality/domain-proto/lib/domain'; +import uniqBy from 'lodash-es/uniqBy'; +import { BehaviorSubject, from, of } from 'rxjs'; +import uuid from 'uuid'; + +import { ClaimManagementService } from '@cc/app/api/claim-management'; +import { getByType, MetadataFormExtension } from '@cc/app/shared/components/metadata-form'; +import { NotificationService } from '@cc/app/shared/services/notification'; +import { progressTo } from '@cc/utils'; + +function createPartyOptions(values: IterableIterator<{ id: string }>) { + return Array.from(values).map((value) => ({ + label: `${value.id} (from party)`, + details: value, + value: value.id, + })); +} + +function createClaimOptions(modificationUnits: { id: string; modification: unknown }[]) { + return uniqBy( + modificationUnits.filter(Boolean).map((unit) => ({ + label: `${unit.id} (from claim)`, + details: unit.modification, + value: unit.id, + })), + 'value' + ); +} + +function generate() { + return of(uuid()); +} + +@UntilDestroy() +@Component({ + selector: 'cc-add-modification-dialog', + templateUrl: './add-modification-dialog.component.html', +}) +export class AddModificationDialogComponent { + control = this.fb.control(null); + metadata$ = from(import('@vality/domain-proto/lib/metadata.json').then((m) => m.default)); + extensions: MetadataFormExtension[] = [ + { + determinant: (data) => of(!!getByType(data, 'ContractorID', 'domain')), + extension: () => + of({ + options: [ + ...createPartyOptions(this.dialogData.party.contractors.values()), + ...createClaimOptions( + this.dialogData.claim.changeset.map( + (unit) => + unit.modification.party_modification?.contractor_modification + ) + ), + ], + generate, + }), + }, + { + determinant: (data) => of(!!getByType(data, 'ContractID', 'domain')), + extension: () => + of({ + options: [ + ...createPartyOptions(this.dialogData.party.contracts.values()), + ...createClaimOptions( + this.dialogData.claim.changeset.map( + (unit) => + unit.modification.party_modification?.contract_modification + ) + ), + ], + generate, + }), + }, + { + determinant: (data) => of(!!getByType(data, 'ShopID', 'domain')), + extension: () => + of({ + options: [ + ...createPartyOptions(this.dialogData.party.shops.values()), + ...createClaimOptions( + this.dialogData.claim.changeset.map( + (unit) => unit.modification.party_modification?.shop_modification + ) + ), + ], + generate, + }), + }, + { + determinant: (data) => of(!!getByType(data, 'WalletID', 'domain')), + extension: () => + of({ + options: [ + ...createPartyOptions(this.dialogData.party.wallets.values()), + ...createClaimOptions( + this.dialogData.claim.changeset.map( + (unit) => unit.modification.party_modification?.wallet_modification + ) + ), + ], + generate, + }), + }, + { + determinant: (data) => of(!!getByType(data, 'ID', 'base')), + extension: () => of({ generate }), + }, + ]; + progress$ = new BehaviorSubject(0); + + constructor( + private fb: FormBuilder, + private dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) + private dialogData: { party: Party; claim: Claim }, + private claimManagementService: ClaimManagementService, + private notificationService: NotificationService + ) {} + + add() { + this.claimManagementService + .UpdateClaim( + this.dialogData.party.id, + this.dialogData.claim.id, + this.dialogData.claim.revision, + [this.control.value] + ) + .pipe(progressTo(this.progress$), untilDestroyed(this)) + .subscribe({ + next: () => { + this.notificationService.success('Modification added successfully'); + this.dialogRef.close('success'); + }, + error: (err) => { + console.error(err); + this.notificationService.error('Error adding modification'); + }, + }); + } + + cancel() { + this.dialogRef.close(); + } +} diff --git a/src/app/sections/claim/types/claim-status-color.ts b/src/app/sections/claim/types/claim-status-color.ts index 93993458..2c8f3e67 100644 --- a/src/app/sections/claim/types/claim-status-color.ts +++ b/src/app/sections/claim/types/claim-status-color.ts @@ -3,7 +3,7 @@ import { ClaimStatus } from '@vality/domain-proto/lib/claim_management'; import { StatusColor } from '@cc/app/styles'; export const CLAIM_STATUS_COLOR: Record = { - pending_acceptance: StatusColor.Success, + pending_acceptance: null, accepted: StatusColor.Success, pending: StatusColor.Pending, review: StatusColor.Pending, diff --git a/src/app/sections/party-claim/changeset/timeline-components/timeline-item-header/timeline-item-header.component.html b/src/app/sections/party-claim/changeset/timeline-components/timeline-item-header/timeline-item-header.component.html index 8368d24f..d0f2b6a1 100644 --- a/src/app/sections/party-claim/changeset/timeline-components/timeline-item-header/timeline-item-header.component.html +++ b/src/app/sections/party-claim/changeset/timeline-components/timeline-item-header/timeline-item-header.component.html @@ -1,5 +1,5 @@ -{{ username }} - +{{ username }} + {{ text }} at {{ createdAt | date: 'dd.MM.yyyy HH:mm:ss' }} ({{ @@ -7,5 +7,5 @@ }}) - (Outdated) - (Removed) + (Outdated) + (Removed) diff --git a/src/app/shared/components/index.ts b/src/app/shared/components/index.ts index 6b8ecf9b..c51917a6 100644 --- a/src/app/shared/components/index.ts +++ b/src/app/shared/components/index.ts @@ -9,3 +9,4 @@ export * from './shop-field'; export * from './shop-details'; export * from './payout-tool-details'; export * from './payout-tool-field'; +export * from './metadata-form'; diff --git a/src/app/shared/components/json-viewer/json-viewer.component.html b/src/app/shared/components/json-viewer/json-viewer.component.html index 16a38e5d..9fd00acc 100644 --- a/src/app/shared/components/json-viewer/json-viewer.component.html +++ b/src/app/shared/components/json-viewer/json-viewer.component.html @@ -5,7 +5,7 @@ [title]="item.key | keyTitle | titlecase" > + {{ data.field?.name | keyTitle | titlecase }} ({{ data.type.name | titlecase }}) + + + + + {{ i + 1 }}. + {{ + data.type.name === 'map' + ? (data.type.keyType | valueTypeTitle | titlecase) + ' - ' + : '' + }} + {{ data.type.valueType | valueTypeTitle | titlecase }} + + + + + +
+ + Key + + + Value + +
+
+
+ + diff --git a/src/app/shared/components/metadata-form/components/complex-form/complex-form.component.scss b/src/app/shared/components/metadata-form/components/complex-form/complex-form.component.scss new file mode 100644 index 00000000..4677d528 --- /dev/null +++ b/src/app/shared/components/metadata-form/components/complex-form/complex-form.component.scss @@ -0,0 +1,8 @@ +mat-expansion-panel-body { + padding: 0; +} + +mat-expansion-panel-header { + padding: 0 2px 0 0; + background: #fff !important; +} diff --git a/src/app/shared/components/metadata-form/components/complex-form/complex-form.component.ts b/src/app/shared/components/metadata-form/components/complex-form/complex-form.component.ts new file mode 100644 index 00000000..5a5bbfe8 --- /dev/null +++ b/src/app/shared/components/metadata-form/components/complex-form/complex-form.component.ts @@ -0,0 +1,36 @@ +import { Component, Input } from '@angular/core'; +import { ValidationErrors, Validator } from '@angular/forms'; +import { FormArray, FormControl } from '@ngneat/reactive-forms'; +import { WrappedFormControlSuperclass } from '@s-libs/ng-core'; +import { MapType, SetType, ListType } from '@vality/thrift-ts'; + +import { createValidatedAbstractControlProviders } from '@cc/utils'; + +import { MetadataFormData } from '../../types/metadata-form-data'; + +@Component({ + selector: 'cc-complex-form', + templateUrl: './complex-form.component.html', + styleUrls: ['complex-form.component.scss'], + providers: createValidatedAbstractControlProviders(ComplexFormComponent), +}) +export class ComplexFormComponent + extends WrappedFormControlSuperclass + implements Validator +{ + @Input() data: MetadataFormData; + + controls = new FormArray([]); + + add() { + this.controls.push(new FormControl()); + } + + delete(idx: number) { + this.controls.removeAt(idx); + } + + validate(): ValidationErrors | null { + return this.control.invalid || this.controls.invalid ? { invalid: true } : null; + } +} diff --git a/src/app/shared/components/metadata-form/components/enum-field/enum-field.component.html b/src/app/shared/components/metadata-form/components/enum-field/enum-field.component.html new file mode 100644 index 00000000..a4bca21a --- /dev/null +++ b/src/app/shared/components/metadata-form/components/enum-field/enum-field.component.html @@ -0,0 +1,14 @@ + + {{ data.field?.name || data.type | keyTitle | titlecase }} + + {{ item.name }} + + + diff --git a/src/app/shared/components/metadata-form/components/enum-field/enum-field.component.ts b/src/app/shared/components/metadata-form/components/enum-field/enum-field.component.ts new file mode 100644 index 00000000..907a4a8b --- /dev/null +++ b/src/app/shared/components/metadata-form/components/enum-field/enum-field.component.ts @@ -0,0 +1,21 @@ +import { Component, Input } from '@angular/core'; +import { ValidationErrors, Validator } from '@angular/forms'; +import { WrappedFormControlSuperclass } from '@s-libs/ng-core'; +import { Enums } from '@vality/thrift-ts/src/thrift-parser'; + +import { createValidatedAbstractControlProviders } from '@cc/utils'; + +import { MetadataFormData } from '../../types/metadata-form-data'; + +@Component({ + selector: 'cc-enum-field', + templateUrl: './enum-field.component.html', + providers: createValidatedAbstractControlProviders(EnumFieldComponent), +}) +export class EnumFieldComponent extends WrappedFormControlSuperclass implements Validator { + @Input() data: MetadataFormData; + + validate(): ValidationErrors | null { + return this.control.invalid ? { invalid: true } : null; + } +} diff --git a/src/app/shared/components/metadata-form/components/primitive-field/primitive-field.component.html b/src/app/shared/components/metadata-form/components/primitive-field/primitive-field.component.html new file mode 100644 index 00000000..6daed10a --- /dev/null +++ b/src/app/shared/components/metadata-form/components/primitive-field/primitive-field.component.html @@ -0,0 +1,54 @@ +
+
+ + {{ + data.field + ? (data.field.name | keyTitle | titlecase) + : (data.typedefData.type | valueTypeTitle | titlecase) + }} + +
+ + +
+ + + + {{ option.label || option.value }} + + + +
+ +
+ + + + {{ selected.label }} + + + + +
diff --git a/src/app/shared/components/metadata-form/components/primitive-field/primitive-field.component.scss b/src/app/shared/components/metadata-form/components/primitive-field/primitive-field.component.scss new file mode 100644 index 00000000..acdd885b --- /dev/null +++ b/src/app/shared/components/metadata-form/components/primitive-field/primitive-field.component.scss @@ -0,0 +1,4 @@ +::ng-deep .mat-tooltip { + font-family: monospace !important; + white-space: pre-line !important; +} diff --git a/src/app/shared/components/metadata-form/components/primitive-field/primitive-field.component.ts b/src/app/shared/components/metadata-form/components/primitive-field/primitive-field.component.ts new file mode 100644 index 00000000..72961216 --- /dev/null +++ b/src/app/shared/components/metadata-form/components/primitive-field/primitive-field.component.ts @@ -0,0 +1,61 @@ +import { Component, Input, OnChanges } from '@angular/core'; +import { ValidationErrors, Validator } from '@angular/forms'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { WrappedFormControlSuperclass } from '@s-libs/ng-core'; +import { ThriftType } from '@vality/thrift-ts'; +import { combineLatest, defer, ReplaySubject, switchMap } from 'rxjs'; +import { map, pluck, shareReplay } from 'rxjs/operators'; + +import { ComponentChanges } from '@cc/app/shared'; +import { createValidatedAbstractControlProviders } from '@cc/utils'; + +import { MetadataFormData } from '../../types/metadata-form-data'; + +@UntilDestroy() +@Component({ + selector: 'cc-primitive-field', + templateUrl: './primitive-field.component.html', + styleUrls: ['primitive-field.component.scss'], + providers: createValidatedAbstractControlProviders(PrimitiveFieldComponent), +}) +export class PrimitiveFieldComponent + extends WrappedFormControlSuperclass + implements OnChanges, Validator +{ + @Input() data: MetadataFormData; + + extensionResult$ = defer(() => this.data$).pipe( + switchMap((data) => data.extensionResult$), + shareReplay({ refCount: true, bufferSize: 1 }) + ); + generate$ = this.extensionResult$.pipe(pluck('generate')); + selected$ = combineLatest([this.extensionResult$, this.control.valueChanges]).pipe( + map(([extensionResult, value]) => extensionResult.options.find((o) => o.value === value)) + ); + + private data$ = new ReplaySubject>(1); + + ngOnChanges(changes: ComponentChanges) { + super.ngOnChanges(changes); + if (changes.data) this.data$.next(this.data); + } + + validate(): ValidationErrors | null { + return this.control.invalid ? { invalid: true } : null; + } + + generate(event: MouseEvent) { + this.generate$ + .pipe( + switchMap((generate) => generate()), + untilDestroyed(this) + ) + .subscribe((value) => this.control.setValue(value)); + event.stopPropagation(); + } + + clear(event: MouseEvent) { + this.control.reset(null); + event.stopPropagation(); + } +} diff --git a/src/app/shared/components/metadata-form/components/struct-form/struct-form.component.html b/src/app/shared/components/metadata-form/components/struct-form/struct-form.component.html new file mode 100644 index 00000000..0314adc8 --- /dev/null +++ b/src/app/shared/components/metadata-form/components/struct-form/struct-form.component.html @@ -0,0 +1,15 @@ +
+
+ {{ data.field?.name | keyTitle | titlecase }} +
+ +
diff --git a/src/app/shared/components/metadata-form/components/struct-form/struct-form.component.ts b/src/app/shared/components/metadata-form/components/struct-form/struct-form.component.ts new file mode 100644 index 00000000..d1d39dad --- /dev/null +++ b/src/app/shared/components/metadata-form/components/struct-form/struct-form.component.ts @@ -0,0 +1,51 @@ +import { Component, Injector, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { ValidationErrors, Validator } from '@angular/forms'; +import { FormBuilder, FormControl } from '@ngneat/reactive-forms'; +import { UntilDestroy } from '@ngneat/until-destroy'; +import { WrappedControlSuperclass } from '@s-libs/ng-core'; +import { Field } from '@vality/thrift-ts'; + +import { createValidatedAbstractControlProviders } from '@cc/utils'; + +import { MetadataFormData } from '../../types/metadata-form-data'; + +@UntilDestroy() +@Component({ + selector: 'cc-struct-form', + templateUrl: './struct-form.component.html', + providers: createValidatedAbstractControlProviders(StructFormComponent), +}) +export class StructFormComponent + extends WrappedControlSuperclass<{ [N in string]: unknown }> + implements OnChanges, Validator +{ + @Input() data: MetadataFormData; + + control = this.fb.group<{ [N in string]: unknown }>({}); + + constructor(injector: Injector, private fb: FormBuilder) { + super(injector); + } + + ngOnChanges(changes: SimpleChanges) { + super.ngOnChanges(changes); + const newControlsNames = new Set(this.data.ast.map(({ name }) => name)); + Object.keys(this.control.controls).forEach((name) => { + if (newControlsNames.has(name)) newControlsNames.delete(name); + else this.control.removeControl(name); + }); + newControlsNames.forEach((name) => this.control.addControl(name, new FormControl())); + } + + validate(): ValidationErrors | null { + return this.control.invalid ? { invalid: true } : null; + } + + get hasLabel() { + let parent: MetadataFormData = this.data.parent; + while (parent?.objectType === 'typedef') { + parent = parent?.parent; + } + return parent?.objectType !== 'union' && this.data.field; + } +} diff --git a/src/app/shared/components/metadata-form/components/typedef-form/typedef-form.component.html b/src/app/shared/components/metadata-form/components/typedef-form/typedef-form.component.html new file mode 100644 index 00000000..3056a630 --- /dev/null +++ b/src/app/shared/components/metadata-form/components/typedef-form/typedef-form.component.html @@ -0,0 +1,9 @@ + diff --git a/src/app/shared/components/metadata-form/components/typedef-form/typedef-form.component.ts b/src/app/shared/components/metadata-form/components/typedef-form/typedef-form.component.ts new file mode 100644 index 00000000..f37bc33e --- /dev/null +++ b/src/app/shared/components/metadata-form/components/typedef-form/typedef-form.component.ts @@ -0,0 +1,24 @@ +import { Component, Input } from '@angular/core'; +import { ValidationErrors, Validator } from '@angular/forms'; +import { WrappedFormControlSuperclass } from '@s-libs/ng-core'; +import { TypeDefs } from '@vality/thrift-ts'; + +import { createValidatedAbstractControlProviders } from '@cc/utils'; + +import { MetadataFormData } from '../../types/metadata-form-data'; + +@Component({ + selector: 'cc-typedef-form', + templateUrl: './typedef-form.component.html', + providers: createValidatedAbstractControlProviders(TypedefFormComponent), +}) +export class TypedefFormComponent + extends WrappedFormControlSuperclass + implements Validator +{ + @Input() data: MetadataFormData; + + validate(): ValidationErrors | null { + return this.control.invalid ? { invalid: true } : null; + } +} diff --git a/src/app/shared/components/metadata-form/components/union-field/union-field.component.html b/src/app/shared/components/metadata-form/components/union-field/union-field.component.html new file mode 100644 index 00000000..f1f933f0 --- /dev/null +++ b/src/app/shared/components/metadata-form/components/union-field/union-field.component.html @@ -0,0 +1,28 @@ +
+ + {{ data.field?.name || data.type | keyTitle | titlecase }} + + {{ + field.name | keyTitle | titlecase + }} + + + + +
diff --git a/src/app/shared/components/metadata-form/components/union-field/union-field.component.ts b/src/app/shared/components/metadata-form/components/union-field/union-field.component.ts new file mode 100644 index 00000000..ad7e471e --- /dev/null +++ b/src/app/shared/components/metadata-form/components/union-field/union-field.component.ts @@ -0,0 +1,64 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { ValidationErrors, Validator } from '@angular/forms'; +import { FormControl } from '@ngneat/reactive-forms'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { FormComponentSuperclass } from '@s-libs/ng-core'; +import { Field } from '@vality/thrift-ts'; +import { merge } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; + +import { createValidatedAbstractControlProviders } from '@cc/utils'; + +import { MetadataFormData } from '../../types/metadata-form-data'; + +@UntilDestroy() +@Component({ + selector: 'cc-union-field', + templateUrl: './union-field.component.html', + providers: createValidatedAbstractControlProviders(UnionFieldComponent), +}) +export class UnionFieldComponent + extends FormComponentSuperclass<{ [N in string]: unknown }> + implements OnInit, Validator +{ + @Input() data: MetadataFormData; + + fieldControl = new FormControl(); + internalControl = new FormControl(); + + ngOnInit() { + this.fieldControl.valueChanges.pipe(untilDestroyed(this)).subscribe(() => { + this.internalControl.reset(null, { emitEvent: false }); + }); + merge(this.fieldControl.valueChanges, this.internalControl.valueChanges) + .pipe( + map(() => { + const field = this.fieldControl.value; + return field ? { [field.name]: this.internalControl.value } : null; + }), + distinctUntilChanged(), + untilDestroyed(this) + ) + .subscribe((value) => { + this.emitOutgoingValue(value); + }); + } + + validate(): ValidationErrors | null { + return this.fieldControl.invalid || this.internalControl.invalid ? { invalid: true } : null; + } + + handleIncomingValue(value: { [N in string]: unknown }) { + if (value) { + const name = Object.keys(value)[0]; + this.fieldControl.setValue( + this.data.ast.find((f) => f.name === name), + { emitEvent: false } + ); + this.internalControl.setValue(value[name], { emitEvent: false }); + } else { + this.fieldControl.reset(null, { emitEvent: false }); + this.internalControl.reset(null, { emitEvent: false }); + } + } +} diff --git a/src/app/shared/components/metadata-form/index.ts b/src/app/shared/components/metadata-form/index.ts new file mode 100644 index 00000000..76dd9707 --- /dev/null +++ b/src/app/shared/components/metadata-form/index.ts @@ -0,0 +1,2 @@ +export * from './metadata-form.module'; +export * from './types/metadata-form-data'; diff --git a/src/app/shared/components/metadata-form/metadata-form.component.html b/src/app/shared/components/metadata-form/metadata-form.component.html new file mode 100644 index 00000000..2ca432bd --- /dev/null +++ b/src/app/shared/components/metadata-form/metadata-form.component.html @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/src/app/shared/components/metadata-form/metadata-form.component.ts b/src/app/shared/components/metadata-form/metadata-form.component.ts new file mode 100644 index 00000000..22881192 --- /dev/null +++ b/src/app/shared/components/metadata-form/metadata-form.component.ts @@ -0,0 +1,45 @@ +import { Component, Input, OnChanges } from '@angular/core'; +import { ValidationErrors, Validator } from '@angular/forms'; +import { WrappedFormControlSuperclass } from '@s-libs/ng-core'; +import { Field, ValueType } from '@vality/thrift-ts'; + +import { ThriftAstMetadata } from '@cc/app/api/utils'; +import { createValidatedAbstractControlProviders } from '@cc/utils'; + +import { MetadataFormData, MetadataFormExtension } from './types/metadata-form-data'; + +@Component({ + selector: 'cc-metadata-form', + templateUrl: './metadata-form.component.html', + providers: createValidatedAbstractControlProviders(MetadataFormComponent), +}) +export class MetadataFormComponent + extends WrappedFormControlSuperclass + implements OnChanges, Validator +{ + @Input() metadata: ThriftAstMetadata[]; + @Input() namespace: string; + @Input() type: ValueType; + @Input() field?: Field; + @Input() parent?: MetadataFormData; + @Input() extensions?: MetadataFormExtension[]; + + data: MetadataFormData; + + ngOnChanges() { + if (this.metadata && this.namespace && this.type) { + this.data = new MetadataFormData( + this.metadata, + this.namespace, + this.type, + this.field, + this.parent, + this.extensions + ); + } + } + + validate(): ValidationErrors | null { + return this.control.invalid ? { invalid: true } : null; + } +} diff --git a/src/app/shared/components/metadata-form/metadata-form.module.ts b/src/app/shared/components/metadata-form/metadata-form.module.ts new file mode 100644 index 00000000..e2301b82 --- /dev/null +++ b/src/app/shared/components/metadata-form/metadata-form.module.ts @@ -0,0 +1,56 @@ +import { OverlayModule } from '@angular/cdk/overlay'; +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FlexModule, GridModule } from '@angular/flex-layout'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +import { ThriftPipesModule, ValueTypeTitleModule } from '@cc/app/shared'; +import { JsonViewerModule } from '@cc/app/shared/components/json-viewer'; + +import { ComplexFormComponent } from './components/complex-form/complex-form.component'; +import { EnumFieldComponent } from './components/enum-field/enum-field.component'; +import { PrimitiveFieldComponent } from './components/primitive-field/primitive-field.component'; +import { StructFormComponent } from './components/struct-form/struct-form.component'; +import { TypedefFormComponent } from './components/typedef-form/typedef-form.component'; +import { UnionFieldComponent } from './components/union-field/union-field.component'; +import { MetadataFormComponent } from './metadata-form.component'; + +@NgModule({ + imports: [ + CommonModule, + MatInputModule, + GridModule, + ThriftPipesModule, + MatSelectModule, + MatButtonModule, + ReactiveFormsModule, + MatAutocompleteModule, + MatTooltipModule, + MatIconModule, + JsonViewerModule, + OverlayModule, + MatCardModule, + MatExpansionModule, + FlexModule, + ValueTypeTitleModule, + ], + declarations: [ + MetadataFormComponent, + PrimitiveFieldComponent, + ComplexFormComponent, + StructFormComponent, + UnionFieldComponent, + TypedefFormComponent, + EnumFieldComponent, + ], + exports: [MetadataFormComponent], +}) +export class MetadataFormModule {} diff --git a/src/app/shared/components/metadata-form/types/metadata-form-data.ts b/src/app/shared/components/metadata-form/types/metadata-form-data.ts new file mode 100644 index 00000000..a7393af8 --- /dev/null +++ b/src/app/shared/components/metadata-form/types/metadata-form-data.ts @@ -0,0 +1,117 @@ +import { Field, ValueType } from '@vality/thrift-ts'; +import { JsonAST } from '@vality/thrift-ts/src/thrift-parser'; +import { Observable, combineLatest, switchMap } from 'rxjs'; +import { map, pluck, shareReplay } from 'rxjs/operators'; + +import { + isComplexType, + isPrimitiveType, + parseNamespaceObjectType, + parseNamespaceType, + StructureType, + ThriftAstMetadata, +} from '@cc/app/api/utils'; + +export interface MetadataFormExtensionResult { + options?: { + value: unknown; + label?: string; + details?: string | object; + }[]; + generate?: () => Observable; +} + +export type MetadataFormExtension = { + determinant: (data: MetadataFormData) => Observable; + extension: (data: MetadataFormData) => Observable; +}; + +export enum TypeGroup { + Complex = 'complex', + Primitive = 'primitive', + Object = 'object', +} + +export function getAliases(data: MetadataFormData) { + const path: MetadataFormData[] = data.parent ? [data.parent] : []; + while (path[path.length - 1]?.objectType === 'typedef') { + path.push(path[path.length - 1].parent); + } + return path; +} + +export function getByType(data: MetadataFormData, type: string, namespace: string) { + return [data, ...getAliases(data)].find((d) => d.type === type && d.namespace === namespace); +} + +type ObjectAst = JsonAST[keyof JsonAST][keyof JsonAST[keyof JsonAST]]; + +export class MetadataFormData { + typeGroup: TypeGroup; + + namespace: string; + type: T; + + objectType?: StructureType; + ast?: M; + + get typedefData() { + let data: MetadataFormData = this as MetadataFormData; + while (data.parent?.objectType === 'typedef') { + data = data.parent; + } + return data; + } + + /** + * The first one identified is used + */ + extensionResult$: Observable; + + constructor( + public metadata: ThriftAstMetadata[], + namespace: string, + type: T, + public field?: Field, + public parent?: MetadataFormData, + public extensions?: MetadataFormExtension[] + ) { + this.setNamespaceType(namespace, type); + this.setTypeGroup(); + if (this.typeGroup === TypeGroup.Object) this.setNamespaceObjectType(); + } + + private setNamespaceType(namespace: string, type: T) { + const namespaceType = parseNamespaceType(type, namespace); + this.namespace = namespaceType.namespace; + this.type = namespaceType.type; + this.extensionResult$ = combineLatest( + (this.extensions || []).map(({ determinant }) => determinant(this)) + ).pipe( + map((determined) => this.extensions.filter((_, idx) => determined[idx])), + switchMap((extensions) => combineLatest(extensions.map((e) => e.extension(this)))), + pluck(0), + shareReplay({ refCount: true, bufferSize: 1 }) + ); + } + + private setTypeGroup(type: ValueType = this.type) { + this.typeGroup = isComplexType(type) + ? TypeGroup.Complex + : isPrimitiveType(this.type) + ? TypeGroup.Primitive + : TypeGroup.Object; + } + + private setNamespaceObjectType() { + const namespaceObjectType = parseNamespaceObjectType( + this.metadata, + this.namespace, + this.type as string + ); + this.objectType = namespaceObjectType.objectType; + this.ast = (namespaceObjectType.namespaceMetadata.ast[this.objectType] as unknown)[ + this.type + ] as M; + } +} diff --git a/src/app/shared/pipes/index.ts b/src/app/shared/pipes/index.ts index 2e814f3f..6346d856 100644 --- a/src/app/shared/pipes/index.ts +++ b/src/app/shared/pipes/index.ts @@ -1,3 +1,4 @@ export * from './thrift'; export * from './common'; export * from './api-model-types'; +export * from './value-type-title/value-type-title.module'; diff --git a/src/app/shared/pipes/value-type-title/value-type-title.module.ts b/src/app/shared/pipes/value-type-title/value-type-title.module.ts new file mode 100644 index 00000000..afdb9eb7 --- /dev/null +++ b/src/app/shared/pipes/value-type-title/value-type-title.module.ts @@ -0,0 +1,11 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { ValueTypeTitlePipe } from './value-type-title.pipe'; + +@NgModule({ + declarations: [ValueTypeTitlePipe], + imports: [CommonModule], + exports: [ValueTypeTitlePipe], +}) +export class ValueTypeTitleModule {} diff --git a/src/app/shared/pipes/value-type-title/value-type-title.pipe.ts b/src/app/shared/pipes/value-type-title/value-type-title.pipe.ts new file mode 100644 index 00000000..27f3beed --- /dev/null +++ b/src/app/shared/pipes/value-type-title/value-type-title.pipe.ts @@ -0,0 +1,26 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { ValueType } from '@vality/thrift-ts'; +import lowerCase from 'lodash-es/lowerCase'; + +import { isComplexType } from '@cc/app/api/utils'; + +@Pipe({ + name: 'valueTypeTitle', +}) +export class ValueTypeTitlePipe implements PipeTransform { + transform(valueType: ValueType): string { + return this.getTypeName(valueType); + } + + private getTypeName(valueType: ValueType): string { + if (isComplexType(valueType)) { + if (valueType.name === 'map') { + return `${valueType.name}: ${this.getTypeName( + valueType.keyType + )} - ${this.getTypeName(valueType.valueType)}`; + } + return `${valueType.name}: ${this.getTypeName(valueType.valueType)}`; + } + return lowerCase(valueType); + } +} diff --git a/src/components/details-item/details-item.component.html b/src/components/details-item/details-item.component.html index 2ce50f4c..3cdf0739 100644 --- a/src/components/details-item/details-item.component.html +++ b/src/components/details-item/details-item.component.html @@ -1,7 +1,11 @@
{{ title }}
-
-
-
-
+
+
+ +
+
+ hide_source +
diff --git a/src/components/details-item/details-item.component.ts b/src/components/details-item/details-item.component.ts index e9f1a727..1fdab3dd 100644 --- a/src/components/details-item/details-item.component.ts +++ b/src/components/details-item/details-item.component.ts @@ -1,11 +1,41 @@ -import { Component, Input } from '@angular/core'; +import { ContentObserver } from '@angular/cdk/observers'; +import { AfterViewInit, Component, ElementRef, Input, ViewChild } from '@angular/core'; +import { defer, ReplaySubject, switchMap } from 'rxjs'; +import { delay, distinctUntilChanged, map, share, startWith } from 'rxjs/operators'; @Component({ selector: 'cc-details-item', templateUrl: 'details-item.component.html', styleUrls: ['details-item.component.scss'], }) -export class DetailsItemComponent { - @Input() - title: string; +export class DetailsItemComponent implements AfterViewInit { + @Input() title: string; + + @ViewChild('content') contentElementRef: ElementRef; + + isEmpty$ = defer(() => this.viewInit$).pipe( + switchMap(() => + this.contentObserver.observe(this.contentElementRef.nativeElement).pipe(startWith(null)) + ), + map(() => this.getIsEmpty()), + distinctUntilChanged(), + delay(0), + share() + ); + + private viewInit$ = new ReplaySubject(1); + + constructor(private contentObserver: ContentObserver) {} + + ngAfterViewInit() { + this.viewInit$.next(); + } + + private getIsEmpty() { + return !Array.from(this.contentElementRef.nativeElement.childNodes).find( + (n) => + n.nodeType !== Node.COMMENT_NODE || + (n.nodeType === Node.TEXT_NODE && n.nodeValue.trim()) + ); + } } diff --git a/src/components/timeline/timeline-item/timeline-item-badge/timeline-item-badge.component.scss b/src/components/timeline/timeline-item/timeline-item-badge/timeline-item-badge.component.scss index 8ea15aa9..310820c9 100644 --- a/src/components/timeline/timeline-item/timeline-item-badge/timeline-item-badge.component.scss +++ b/src/components/timeline/timeline-item/timeline-item-badge/timeline-item-badge.component.scss @@ -1,6 +1,6 @@ @import '../timeline-item.scss'; -$size: 36px; +$size: 48px; $line-size: 2px; .cc-timeline-item-badge { diff --git a/src/components/timeline/timeline-item/timeline-item-content/timeline-item-content.component.scss b/src/components/timeline/timeline-item/timeline-item-content/timeline-item-content.component.scss index 9a301e70..e4227972 100644 --- a/src/components/timeline/timeline-item/timeline-item-content/timeline-item-content.component.scss +++ b/src/components/timeline/timeline-item/timeline-item-content/timeline-item-content.component.scss @@ -1,5 +1,6 @@ @import '../timeline-item.scss'; .cc-timeline-item-content { + display: block; padding-top: $content-padding; } diff --git a/src/components/timeline/timeline-item/timeline-item-title/timeline-item-title.component.html b/src/components/timeline/timeline-item/timeline-item-title/timeline-item-title.component.html index b6f9f5ba..9ec55968 100644 --- a/src/components/timeline/timeline-item/timeline-item-title/timeline-item-title.component.html +++ b/src/components/timeline/timeline-item/timeline-item-title/timeline-item-title.component.html @@ -1,3 +1,5 @@
-
+
+ +
diff --git a/src/components/timeline/timeline-item/timeline-item.component.scss b/src/components/timeline/timeline-item/timeline-item.component.scss index b60e0223..31715b19 100644 --- a/src/components/timeline/timeline-item/timeline-item.component.scss +++ b/src/components/timeline/timeline-item/timeline-item.component.scss @@ -1,7 +1,7 @@ @import './timeline-item.scss'; .cc-timeline-item { - padding-bottom: 32px; - padding-left: $size + $content-padding + 6; + padding-bottom: 24px; + padding-left: $size + 16px; position: relative; } diff --git a/src/components/timeline/timeline-item/timeline-item.scss b/src/components/timeline/timeline-item/timeline-item.scss index 0864d799..dc5f3867 100644 --- a/src/components/timeline/timeline-item/timeline-item.scss +++ b/src/components/timeline/timeline-item/timeline-item.scss @@ -1,4 +1,4 @@ -$size: 36px; -$icons-size: 18px; +$size: 48px; +$icons-size: 24px; $line-size: 2px; $content-padding: 8px; diff --git a/src/components/timeline/timeline.component.scss b/src/components/timeline/timeline.component.scss index ded931fc..2c20e9dd 100644 --- a/src/components/timeline/timeline.component.scss +++ b/src/components/timeline/timeline.component.scss @@ -1,4 +1,4 @@ -$size: 36px; +$size: 48px; $line-size: 2px; .cc-timeline {