IMP-29: Add new claims modifications manager (#82)

This commit is contained in:
Rinat Arsaev 2022-05-06 21:57:31 +03:00 committed by GitHub
parent ee208676be
commit 5969598e72
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1081 additions and 63 deletions

View File

@ -31,6 +31,7 @@
"thrift-ts",
"buffer",
"humanize-duration",
"node-int64",
"@vality/deanonimus-proto",
"@vality/domain-proto",
"@vality/dominant-cache-proto",

View File

@ -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<V>(
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<V>(
} catch (error) {
console.error(
'Thrift structure',
structureType,
objectType,
'creation error:',
namespace,
type,

View File

@ -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<T extends ValueType = ValueType> {
namespace: string;
type: T;
}
export function parseNamespaceType<T extends ValueType>(
type: T,
namespace?: string
): NamespaceType<T> {
if (!isPrimitiveType(type) && !isComplexType(type) && type.includes('.')) {
[namespace, type as unknown] = type.split('.');
}
return { namespace, type };
}

View File

@ -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<V>(
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': {

View File

@ -93,5 +93,14 @@
</ng-container>
</cc-timeline>
</ng-template>
<mat-card>
<mat-card-content fxLayoutGap="24px">
<button mat-button color="primary" (click)="addModification()">
ADD MODIFICATION
</button>
<!-- <button mat-button>ATTACH FILE</button>-->
<!-- <button mat-button>CHANGE STATUS</button>-->
</mat-card-content>
</mat-card>
</div>
</div>

View File

@ -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<Record<string, string>>).pipe(
switchMap(({ claimID, partyID }) =>
claim$ = merge(
this.route.params,
defer(() => this.loadClaim$)
).pipe(
map(() => this.route.snapshot.params as Record<string, string>),
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<void>();
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();
});
}
}

View File

@ -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 {}

View File

@ -0,0 +1,24 @@
<div fxLayout="column" fxLayoutGap="32px">
<div class="cc-headline">Add modification unit</div>
<cc-metadata-form
[formControl]="control"
[metadata]="metadata$ | async"
[extensions]="extensions"
[disabled]="!!(progress$ | async)"
namespace="claim_management"
type="Modification"
></cc-metadata-form>
<div fxLayout fxLayoutAlign="space-between">
<button mat-button (click)="cancel()" [disabled]="!!(progress$ | async)">CANCEL</button>
<button
mat-button
color="primary"
(click)="add()"
[disabled]="control.invalid || !!(progress$ | async)"
>
ADD
</button>
</div>
</div>

View File

@ -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<Modification>(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<AddModificationDialogComponent>,
@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();
}
}

View File

@ -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<keyof ClaimStatus, StatusColor> = {
pending_acceptance: StatusColor.Success,
pending_acceptance: null,
accepted: StatusColor.Success,
pending: StatusColor.Pending,
review: StatusColor.Pending,

View File

@ -1,5 +1,5 @@
<span [ngClass]="{ 'cc-body-2': true, outdated: outdated, removed: removed }">{{ username }}</span>
<span [ngClass]="{ 'cc-body-1': true, outdated: outdated, removed: removed }">
<span [ngClass]="{ outdated: outdated, removed: removed }">{{ username }}</span>
<span [ngClass]="{ outdated: outdated, removed: removed }">
{{ text }}
<span *ngIf="createdAt"
>at {{ createdAt | date: 'dd.MM.yyyy HH:mm:ss' }} ({{
@ -7,5 +7,5 @@
}})</span
>
</span>
<span class="cc-body-1" *ngIf="outdated"> (Outdated)</span>
<span class="cc-body-1" *ngIf="removed"> (Removed)</span>
<span *ngIf="outdated"> (Outdated)</span>
<span *ngIf="removed"> (Removed)</span>

View File

@ -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';

View File

@ -5,7 +5,7 @@
[title]="item.key | keyTitle | titlecase"
>
<span
style="cursor: default"
*ngIf="!!item.value"
[matTooltip]="item.tooltip"
fxLayoutGap="4px"
fxLayoutAlign=" center"

View File

@ -1,4 +1,6 @@
import isEmpty from 'lodash-es/isEmpty';
import isNil from 'lodash-es/isNil';
import isObject from 'lodash-es/isObject';
import { Patch } from '../types/patch';
@ -20,7 +22,7 @@ export class InlineItem {
}
get isEmpty() {
return isEmpty(this.sourceValue);
return isObject(this.sourceValue) ? isEmpty(this.sourceValue) : isNil(this.sourceValue);
}
constructor(public path: string[], public sourceValue: unknown, private patch?: Patch) {

View File

@ -0,0 +1,51 @@
<div gdColumns="1fr" gdGap="16px">
<span class="cc-body-2"
>{{ data.field?.name | keyTitle | titlecase }} ({{ data.type.name | titlecase }})</span
>
<mat-accordion>
<mat-expansion-panel
class="mat-elevation-z0"
*ngFor="let control of controls.controls; let i = index"
>
<mat-expansion-panel-header>
<mat-panel-title fxLayoutAlign=" center">
{{ i + 1 }}.
{{
data.type.name === 'map'
? (data.type.keyType | valueTypeTitle | titlecase) + ' - '
: ''
}}
{{ data.type.valueType | valueTypeTitle | titlecase }}
</mat-panel-title>
<mat-panel-description fxLayoutAlign="end">
<button mat-icon-button (click)="delete(i)">
<mat-icon>delete</mat-icon>
</button>
</mat-panel-description>
</mat-expansion-panel-header>
<div gdColumns="1fr" gdGap="16px">
<ng-container *ngIf="data.type.name === 'map'">
<span class="cc-body-2">Key</span>
<cc-metadata-form
[metadata]="data.metadata"
[namespace]="data.namespace"
[type]="data.type.keyType"
[parent]="data"
[extensions]="data.extensions"
></cc-metadata-form>
</ng-container>
<span class="cc-body-2" *ngIf="data.type.name === 'map'">Value</span>
<cc-metadata-form
[metadata]="data.metadata"
[namespace]="data.namespace"
[type]="data.type.valueType"
[parent]="data"
[extensions]="data.extensions"
></cc-metadata-form>
</div>
</mat-expansion-panel>
</mat-accordion>
<button mat-button (click)="add()">
ADD {{ data.type.valueType | valueTypeTitle | uppercase }}
</button>
</div>

View File

@ -0,0 +1,8 @@
mat-expansion-panel-body {
padding: 0;
}
mat-expansion-panel-header {
padding: 0 2px 0 0;
background: #fff !important;
}

View File

@ -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<unknown>
implements Validator
{
@Input() data: MetadataFormData<SetType | MapType | ListType>;
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;
}
}

View File

@ -0,0 +1,14 @@
<mat-form-field style="width: 100%">
<mat-label>{{ data.field?.name || data.type | keyTitle | titlecase }}</mat-label>
<mat-select [formControl]="control" [required]="data.field?.option === 'required'">
<mat-option *ngFor="let item of data.ast.items" [value]="item">{{ item.name }}</mat-option>
</mat-select>
<button
matSuffix
mat-icon-button
*ngIf="control.value && data.field?.option !== 'required'"
(click)="control.reset(); $event.stopPropagation()"
>
<mat-icon>clear</mat-icon>
</button>
</mat-form-field>

View File

@ -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<unknown> implements Validator {
@Input() data: MetadataFormData<string, Enums[string]>;
validate(): ValidationErrors | null {
return this.control.invalid ? { invalid: true } : null;
}
}

View File

@ -0,0 +1,54 @@
<div gdColumns="1fr" gdGap="16px">
<div fxLayoutGap="4px">
<mat-form-field fxFlex>
<mat-label>{{
data.field
? (data.field.name | keyTitle | titlecase)
: (data.typedefData.type | valueTypeTitle | titlecase)
}}</mat-label>
<input
matInput
[matAutocomplete]="auto"
#trigger="matAutocompleteTrigger"
[formControl]="control"
[required]="data.field?.option === 'required'"
/>
<div matSuffix fxLayoutGap="4px">
<button mat-icon-button *ngIf="control.value" (click)="clear($event)">
<mat-icon>clear</mat-icon>
</button>
<button
*ngIf="(extensionResult$ | async)?.options?.length"
mat-icon-button
(click)="
auto.isOpen ? trigger.closePanel() : trigger.openPanel();
$event.stopPropagation()
"
>
<mat-icon>{{ auto.isOpen ? 'expand_less' : 'expand_more' }}</mat-icon>
</button>
</div>
<mat-autocomplete #auto="matAutocomplete">
<ng-container *ngIf="data.extensionResult$ | async as extensionResult">
<mat-option
*ngFor="let option of extensionResult.options"
[value]="option.value"
>
{{ option.label || option.value }}
</mat-option>
</ng-container>
</mat-autocomplete>
</mat-form-field>
<button *ngIf="generate$ | async" mat-icon-button (click)="generate($event)">
<mat-icon>loop</mat-icon>
</button>
</div>
<ng-container *ngIf="selected$ | async as selected">
<mat-expansion-panel *ngIf="selected.details">
<mat-expansion-panel-header>
<mat-panel-title>{{ selected.label }}</mat-panel-title>
</mat-expansion-panel-header>
<cc-json-viewer [json]="selected.details"></cc-json-viewer>
</mat-expansion-panel>
</ng-container>
</div>

View File

@ -0,0 +1,4 @@
::ng-deep .mat-tooltip {
font-family: monospace !important;
white-space: pre-line !important;
}

View File

@ -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<unknown>
implements OnChanges, Validator
{
@Input() data: MetadataFormData<ThriftType>;
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<MetadataFormData<ThriftType>>(1);
ngOnChanges(changes: ComponentChanges<PrimitiveFieldComponent>) {
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();
}
}

View File

@ -0,0 +1,15 @@
<div gdColumns="1fr" gdGap="16px" style="padding-left: 16px">
<div *ngIf="hasLabel" class="cc-body-1">
{{ data.field?.name | keyTitle | titlecase }}
</div>
<cc-metadata-form
*ngFor="let field of data.ast"
[formControl]="control.get(field.name)"
[metadata]="data.metadata"
[namespace]="data.namespace"
[type]="field.type"
[field]="field"
[parent]="data"
[extensions]="data.extensions"
></cc-metadata-form>
</div>

View File

@ -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<string, Field[]>;
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;
}
}

View File

@ -0,0 +1,9 @@
<cc-metadata-form
[formControl]="control"
[metadata]="data.metadata"
[namespace]="data.namespace"
[type]="data.ast.type"
[field]="data.field"
[parent]="data"
[extensions]="data.extensions"
></cc-metadata-form>

View File

@ -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<unknown>
implements Validator
{
@Input() data: MetadataFormData<string, TypeDefs[string]>;
validate(): ValidationErrors | null {
return this.control.invalid ? { invalid: true } : null;
}
}

View File

@ -0,0 +1,28 @@
<div gdColumns="1fr" gdGap="16px">
<mat-form-field style="width: 100%">
<mat-label>{{ data.field?.name || data.type | keyTitle | titlecase }}</mat-label>
<mat-select [formControl]="fieldControl" [required]="data.field?.option === 'required'">
<mat-option *ngFor="let field of data.ast" [value]="field">{{
field.name | keyTitle | titlecase
}}</mat-option>
</mat-select>
<button
matSuffix
mat-icon-button
*ngIf="fieldControl.value && data.field?.option !== 'required'"
(click)="fieldControl.reset(); $event.stopPropagation()"
>
<mat-icon>clear</mat-icon>
</button>
</mat-form-field>
<cc-metadata-form
*ngIf="fieldControl.value"
[formControl]="internalControl"
[metadata]="data.metadata"
[namespace]="data.namespace"
[type]="fieldControl.value.type"
[field]="fieldControl.value"
[parent]="data"
[extensions]="data.extensions"
></cc-metadata-form>
</div>

View File

@ -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<string, Field[]>;
fieldControl = new FormControl<Field>();
internalControl = new FormControl<unknown>();
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 });
}
}
}

View File

@ -0,0 +1,2 @@
export * from './metadata-form.module';
export * from './types/metadata-form-data';

View File

@ -0,0 +1,30 @@
<ng-container [ngSwitch]="data?.typeGroup">
<cc-primitive-field
*ngSwitchCase="'primitive'"
[formControl]="control"
[data]="data"
></cc-primitive-field>
<cc-complex-form
*ngSwitchCase="'complex'"
[formControl]="control"
[data]="data"
></cc-complex-form>
<ng-container *ngSwitchCase="'object'" [ngSwitch]="data.objectType">
<cc-struct-form
*ngSwitchCase="'struct'"
[formControl]="control"
[data]="data"
></cc-struct-form>
<cc-union-field
*ngSwitchCase="'union'"
[formControl]="control"
[data]="data"
></cc-union-field>
<cc-enum-field *ngSwitchCase="'enum'" [formControl]="control" [data]="data"></cc-enum-field>
<cc-typedef-form
*ngSwitchCase="'typedef'"
[formControl]="control"
[data]="data"
></cc-typedef-form>
</ng-container>
</ng-container>

View File

@ -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<unknown>
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;
}
}

View File

@ -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 {}

View File

@ -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<unknown>;
}
export type MetadataFormExtension = {
determinant: (data: MetadataFormData) => Observable<boolean>;
extension: (data: MetadataFormData) => Observable<MetadataFormExtensionResult>;
};
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<T extends ValueType = ValueType, M extends ObjectAst = ObjectAst> {
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<MetadataFormExtensionResult>;
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<T>(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;
}
}

View File

@ -1,3 +1,4 @@
export * from './thrift';
export * from './common';
export * from './api-model-types';
export * from './value-type-title/value-type-title.module';

View File

@ -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 {}

View File

@ -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);
}
}

View File

@ -1,7 +1,11 @@
<div fxLayout="column" fxLayoutGap="8px">
<div class="cc-details-item-title mat-caption">{{ title }}</div>
<div class="mat-body-1">
<div #content class="cc-details-item-content"><ng-content></ng-content></div>
<div class="cc-details-item-content-empty" *ngIf="!content.innerHTML.length">-</div>
<div class="mat-body-1 cc-details-item-content">
<div #content>
<ng-content></ng-content>
</div>
<div class="cc-details-item-content-empty" *ngIf="isEmpty$ | async">
<mat-icon inline>hide_source</mat-icon>
</div>
</div>
</div>

View File

@ -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<HTMLElement>;
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<void>(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())
);
}
}

View File

@ -1,6 +1,6 @@
@import '../timeline-item.scss';
$size: 36px;
$size: 48px;
$line-size: 2px;
.cc-timeline-item-badge {

View File

@ -1,5 +1,6 @@
@import '../timeline-item.scss';
.cc-timeline-item-content {
display: block;
padding-top: $content-padding;
}

View File

@ -1,3 +1,5 @@
<div class="cc-timeline-item-title">
<div fxFlex fxLayout="row" fxLayoutAlign="space-between center"><ng-content></ng-content></div>
<div fxFlex fxLayout="row" fxLayoutAlign="space-between center" class="cc-subheading-2">
<ng-content></ng-content>
</div>
</div>

View File

@ -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;
}

View File

@ -1,4 +1,4 @@
$size: 36px;
$icons-size: 18px;
$size: 48px;
$icons-size: 24px;
$line-size: 2px;
$content-padding: 8px;

View File

@ -1,4 +1,4 @@
$size: 36px;
$size: 48px;
$line-size: 2px;
.cc-timeline {