22 changed files with 725 additions and 98 deletions
@ -0,0 +1,71 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2025 The Thingsboard Authors
|
||||
|
///
|
||||
|
/// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
/// you may not use this file except in compliance with the License.
|
||||
|
/// You may obtain a copy of the License at
|
||||
|
///
|
||||
|
/// http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
///
|
||||
|
/// Unless required by applicable law or agreed to in writing, software
|
||||
|
/// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
|
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
|
/// See the License for the specific language governing permissions and
|
||||
|
/// limitations under the License.
|
||||
|
///
|
||||
|
|
||||
|
import { ChangeDetectorRef, Component, DestroyRef, forwardRef, Renderer2, ViewContainerRef, } from '@angular/core'; |
||||
|
import { FormBuilder, NG_VALIDATORS, NG_VALUE_ACCESSOR, } from '@angular/forms'; |
||||
|
import { TbPopoverService } from '@shared/components/popover.service'; |
||||
|
import { EntityService } from '@core/http/entity.service'; |
||||
|
import { Store } from '@ngrx/store'; |
||||
|
import { AppState } from '@core/core.state'; |
||||
|
import { |
||||
|
CalculatedFieldArgumentsTableComponent |
||||
|
} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component'; |
||||
|
import { ArgumentEntityType } from '@shared/models/calculated-field.models'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'tb-entity-aggregation-arguments-table', |
||||
|
templateUrl: './calculated-field-arguments-table.component.html', |
||||
|
styleUrls: [`calculated-field-arguments-table.component.scss`], |
||||
|
providers: [ |
||||
|
{ |
||||
|
provide: NG_VALUE_ACCESSOR, |
||||
|
useExisting: forwardRef(() => EntityAggregationArgumentsTableComponent), |
||||
|
multi: true |
||||
|
}, |
||||
|
{ |
||||
|
provide: NG_VALIDATORS, |
||||
|
useExisting: forwardRef(() => EntityAggregationArgumentsTableComponent), |
||||
|
multi: true |
||||
|
} |
||||
|
], |
||||
|
}) |
||||
|
export class EntityAggregationArgumentsTableComponent extends CalculatedFieldArgumentsTableComponent { |
||||
|
|
||||
|
constructor( |
||||
|
protected fb: FormBuilder, |
||||
|
protected popoverService: TbPopoverService, |
||||
|
protected viewContainerRef: ViewContainerRef, |
||||
|
protected cd: ChangeDetectorRef, |
||||
|
protected renderer: Renderer2, |
||||
|
protected entityService: EntityService, |
||||
|
protected destroyRef: DestroyRef, |
||||
|
protected store: Store<AppState> |
||||
|
) { |
||||
|
super(fb, popoverService, viewContainerRef, cd, renderer, entityService, destroyRef, store); |
||||
|
|
||||
|
this.argumentNameColumn = 'calculated-fields.argument-name'; |
||||
|
this.displayColumns = ['name', 'type', 'key', 'actions']; |
||||
|
this.panelAdditionalCtx = { |
||||
|
hiddenEntityTypes: true, |
||||
|
argumentEntityTypes: [ArgumentEntityType.Current], |
||||
|
hint: 'calculated-fields.entity-aggregation.argument-setting-hint', |
||||
|
hiddenDefaultValue: true, |
||||
|
hiddenEntityKeyTypes: true, |
||||
|
}; |
||||
|
|
||||
|
this.isScript = false; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,131 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
Copyright © 2016-2025 The Thingsboard Authors |
||||
|
|
||||
|
Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
you may not use this file except in compliance with the License. |
||||
|
You may obtain a copy of the License at |
||||
|
|
||||
|
http://www.apache.org/licenses/LICENSE-2.0 |
||||
|
|
||||
|
Unless required by applicable law or agreed to in writing, software |
||||
|
distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
See the License for the specific language governing permissions and |
||||
|
limitations under the License. |
||||
|
|
||||
|
--> |
||||
|
<div [formGroup]="entityAggregationConfiguration" class="tb-form-panel no-border no-padding"> |
||||
|
<div class="tb-form-panel"> |
||||
|
<div class="tb-form-panel-title"> |
||||
|
{{ 'calculated-fields.arguments' | translate }} |
||||
|
</div> |
||||
|
<div class="tb-form-hint tb-primary-fill hint-container"> |
||||
|
{{ 'calculated-fields.entity-aggregation.argument-hint' | translate }} |
||||
|
</div> |
||||
|
<tb-entity-aggregation-arguments-table formControlName="arguments" |
||||
|
[entityId]="entityId" |
||||
|
[tenantId]="tenantId" |
||||
|
[entityName]="entityName"/> |
||||
|
</div> |
||||
|
<div class="tb-form-panel"> |
||||
|
<div class="tb-form-panel-title" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.metrics' | translate }}"> |
||||
|
{{ 'calculated-fields.metrics.metrics' | translate }} |
||||
|
</div> |
||||
|
<tb-calculated-field-metrics-table formControlName="metrics" |
||||
|
simpleMode |
||||
|
[arguments]="arguments$ | async" |
||||
|
></tb-calculated-field-metrics-table> |
||||
|
</div> |
||||
|
<div class="tb-form-panel"> |
||||
|
<div class="tb-form-panel-title" tbTruncateWithTooltip tb-hint-tooltip-icon="{{ 'calculated-fields.entity-aggregation.aggregation-interval-hint' | translate }}"> |
||||
|
{{ 'calculated-fields.entity-aggregation.aggregation-interval' | translate }} |
||||
|
</div> |
||||
|
<ng-container formGroupName="interval"> |
||||
|
<div class="flex items-start gap-3 xs:flex-col xs:items-stretch"> |
||||
|
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic"> |
||||
|
<mat-label translate>calculated-fields.aggregate-interval-type</mat-label> |
||||
|
<mat-select formControlName="type" required> |
||||
|
<mat-option *ngFor="let type of AggIntervalTypes" [value]="type"> |
||||
|
{{ AggIntervalTypeTranslations.get(type) | translate }} |
||||
|
</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
<tb-timezone-select |
||||
|
class="flex-1" |
||||
|
appearance="outline" |
||||
|
subscriptSizing="dynamic" |
||||
|
required |
||||
|
userTimezoneByDefault |
||||
|
formControlName="tz"> |
||||
|
</tb-timezone-select> |
||||
|
</div> |
||||
|
@if (entityAggregationConfiguration.get('interval.type').value === AggIntervalType.CUSTOM) { |
||||
|
<tb-time-unit-input required |
||||
|
[minTime]="0" |
||||
|
sameWidthInputs |
||||
|
appearance="outline" |
||||
|
subscriptSizing="dynamic" |
||||
|
labelText="{{ 'calculated-fields.aggregate-interval-value' | translate }}" |
||||
|
requiredText="{{ 'calculated-fields.aggregate-interval-value-required' | translate }}" |
||||
|
formControlName="multiplier"> |
||||
|
</tb-time-unit-input> |
||||
|
} |
||||
|
<div class="tb-form-panel stroked"> |
||||
|
<mat-slide-toggle class="mat-slide flex" formControlName="allowOffsetMillis"> |
||||
|
<div tb-hint-tooltip-icon="{{ 'calculated-fields.entity-aggregation.apply-offset-hint' | translate }}"> |
||||
|
{{ 'calculated-fields.entity-aggregation.apply-offset' | translate }} |
||||
|
</div> |
||||
|
</mat-slide-toggle> |
||||
|
@if (entityAggregationConfiguration.get('interval.allowOffsetMillis').value) { |
||||
|
<tb-time-unit-input required |
||||
|
[minTime]="1" |
||||
|
sameWidthInputs |
||||
|
appearance="outline" |
||||
|
subscriptSizing="dynamic" |
||||
|
labelText="{{ 'calculated-fields.entity-aggregation.offset-value' | translate }}" |
||||
|
requiredText="{{ 'calculated-fields.entity-aggregation.offset-value-required' | translate }}" |
||||
|
formControlName="offsetMillis"> |
||||
|
</tb-time-unit-input> |
||||
|
} |
||||
|
</div> |
||||
|
</ng-container> |
||||
|
<div class="tb-form-panel stroked"> |
||||
|
<mat-slide-toggle class="mat-slide flex" formControlName="allowWatermark"> |
||||
|
<div tb-hint-tooltip-icon="{{ 'calculated-fields.entity-aggregation.wait-delay-hint' | translate }}"> |
||||
|
{{ 'calculated-fields.entity-aggregation.wait-delay' | translate }} |
||||
|
</div> |
||||
|
</mat-slide-toggle> |
||||
|
@if (entityAggregationConfiguration.get('allowWatermark').value) { |
||||
|
<ng-container formGroupName="watermark"> |
||||
|
<tb-time-unit-input required |
||||
|
[minTime]="1" |
||||
|
sameWidthInputs |
||||
|
appearance="outline" |
||||
|
labelText="{{ 'calculated-fields.entity-aggregation.wait-duration' | translate }}" |
||||
|
hintText="{{ 'calculated-fields.entity-aggregation.wait-duration-hint' | translate }}" |
||||
|
requiredText="{{ 'calculated-fields.entity-aggregation.wait-duration-required' | translate }}" |
||||
|
formControlName="duration"> |
||||
|
</tb-time-unit-input> |
||||
|
<tb-time-unit-input required |
||||
|
[minTime]="0" |
||||
|
[maxTime]="entityAggregationConfiguration.get('watermark.duration').value" |
||||
|
sameWidthInputs |
||||
|
appearance="outline" |
||||
|
labelText="{{ 'calculated-fields.entity-aggregation.check-interval' | translate }}" |
||||
|
hintText="{{ 'calculated-fields.entity-aggregation.check-interval-hint' | translate }}" |
||||
|
maxErrorText="{{ 'calculated-fields.entity-aggregation.check-interval-max' | translate }}" |
||||
|
requiredText="{{ 'calculated-fields.entity-aggregation.check-interval-required' | translate }}" |
||||
|
formControlName="checkInterval"> |
||||
|
</tb-time-unit-input> |
||||
|
</ng-container> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
<tb-calculate-field-output formControlName="output" |
||||
|
containerInputClass="grid items-start grid-cols-2 gap-3 xs:grid-cols-1" |
||||
|
simpleMode |
||||
|
hiddenName |
||||
|
disableType |
||||
|
[entityId]="entityId"></tb-calculate-field-output> |
||||
|
</div> |
||||
@ -0,0 +1,205 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2025 The Thingsboard Authors
|
||||
|
///
|
||||
|
/// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
/// you may not use this file except in compliance with the License.
|
||||
|
/// You may obtain a copy of the License at
|
||||
|
///
|
||||
|
/// http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
///
|
||||
|
/// Unless required by applicable law or agreed to in writing, software
|
||||
|
/// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
|
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
|
/// See the License for the specific language governing permissions and
|
||||
|
/// limitations under the License.
|
||||
|
///
|
||||
|
|
||||
|
import { Component, forwardRef, Input } from '@angular/core'; |
||||
|
import { |
||||
|
ControlValueAccessor, |
||||
|
FormBuilder, |
||||
|
NG_VALIDATORS, |
||||
|
NG_VALUE_ACCESSOR, |
||||
|
ValidationErrors, |
||||
|
Validator, |
||||
|
Validators |
||||
|
} from '@angular/forms'; |
||||
|
import { EntityId } from '@shared/models/id/entity-id'; |
||||
|
import { |
||||
|
AggInterval, |
||||
|
AggIntervalType, |
||||
|
AggIntervalTypeTranslations, |
||||
|
CalculatedFieldEntityAggregationConfiguration, |
||||
|
CalculatedFieldOutput, |
||||
|
CalculatedFieldType, |
||||
|
notEmptyObjectValidator, |
||||
|
OutputType |
||||
|
} from '@shared/models/calculated-field.models'; |
||||
|
import { map } from 'rxjs/operators'; |
||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
||||
|
import { HOUR, MINUTE, SECOND } from '@shared/models/time/time.models'; |
||||
|
import { isDefinedAndNotNull } from '@core/utils'; |
||||
|
|
||||
|
interface CalculatedFieldEntityAggregationConfigurationValue extends CalculatedFieldEntityAggregationConfiguration { |
||||
|
interval: AggInterval & {allowOffsetMillis?: boolean}; |
||||
|
allowWatermark: boolean; |
||||
|
} |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'tb-entity-aggregation-component', |
||||
|
templateUrl: './entity-aggregation-component.component.html', |
||||
|
providers: [ |
||||
|
{ |
||||
|
provide: NG_VALUE_ACCESSOR, |
||||
|
useExisting: forwardRef(() => EntityAggregationComponentComponent), |
||||
|
multi: true |
||||
|
}, |
||||
|
{ |
||||
|
provide: NG_VALIDATORS, |
||||
|
useExisting: forwardRef(() => EntityAggregationComponentComponent), |
||||
|
multi: true |
||||
|
} |
||||
|
], |
||||
|
}) |
||||
|
export class EntityAggregationComponentComponent implements ControlValueAccessor, Validator { |
||||
|
|
||||
|
@Input({required: true}) |
||||
|
entityId: EntityId; |
||||
|
|
||||
|
@Input({required: true}) |
||||
|
tenantId: string; |
||||
|
|
||||
|
@Input({required: true}) |
||||
|
entityName: string; |
||||
|
|
||||
|
|
||||
|
entityAggregationConfiguration = this.fb.group({ |
||||
|
arguments: this.fb.control({}, notEmptyObjectValidator()), |
||||
|
metrics: this.fb.control({}, notEmptyObjectValidator()), |
||||
|
interval: this.fb.group({ |
||||
|
type: [AggIntervalType.HOUR], |
||||
|
tz: ['', Validators.required], |
||||
|
multiplier: [HOUR/SECOND, Validators.required], |
||||
|
allowOffsetMillis: [false], |
||||
|
offsetMillis: [MINUTE/SECOND, Validators.required], |
||||
|
}), |
||||
|
allowWatermark: [false], |
||||
|
watermark: this.fb.group({ |
||||
|
duration: [6 * MINUTE / SECOND, Validators.required], |
||||
|
checkInterval: [MINUTE / SECOND, Validators.required], |
||||
|
}), |
||||
|
output: this.fb.control<CalculatedFieldOutput>({ |
||||
|
type: OutputType.Timeseries, |
||||
|
}), |
||||
|
}); |
||||
|
|
||||
|
arguments$ = this.entityAggregationConfiguration.get('arguments').valueChanges.pipe( |
||||
|
map(argumentsObj => Object.keys(argumentsObj)) |
||||
|
); |
||||
|
|
||||
|
AggIntervalType = AggIntervalType; |
||||
|
AggIntervalTypes = Object.values(AggIntervalType) as AggIntervalType[]; |
||||
|
AggIntervalTypeTranslations = AggIntervalTypeTranslations; |
||||
|
|
||||
|
private propagateChange: (config: CalculatedFieldEntityAggregationConfiguration) => void = () => { }; |
||||
|
|
||||
|
constructor(private fb: FormBuilder) { |
||||
|
|
||||
|
this.entityAggregationConfiguration.get('interval.type').valueChanges.pipe( |
||||
|
takeUntilDestroyed() |
||||
|
).subscribe((type: AggIntervalType) => { |
||||
|
this.checkAggIntervalType(type); |
||||
|
}); |
||||
|
|
||||
|
this.entityAggregationConfiguration.get('interval.allowOffsetMillis').valueChanges.pipe( |
||||
|
takeUntilDestroyed() |
||||
|
).subscribe((allow: boolean) => { |
||||
|
this.checkIntervalDuration(allow); |
||||
|
}); |
||||
|
|
||||
|
this.entityAggregationConfiguration.get('allowWatermark').valueChanges.pipe( |
||||
|
takeUntilDestroyed() |
||||
|
).subscribe((allow: boolean) => { |
||||
|
this.checkWatermark(allow); |
||||
|
}); |
||||
|
|
||||
|
this.entityAggregationConfiguration.valueChanges.pipe( |
||||
|
takeUntilDestroyed() |
||||
|
).subscribe((value: CalculatedFieldEntityAggregationConfigurationValue) => { |
||||
|
this.updatedModel(value); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
validate(): ValidationErrors | null { |
||||
|
return this.entityAggregationConfiguration.valid || this.entityAggregationConfiguration.disabled ? null : {invalidPropagateConfig: false}; |
||||
|
} |
||||
|
|
||||
|
writeValue(value: CalculatedFieldEntityAggregationConfiguration): void { |
||||
|
const data: CalculatedFieldEntityAggregationConfigurationValue = { |
||||
|
...value, |
||||
|
allowWatermark: isDefinedAndNotNull(value.watermark), |
||||
|
interval: {...value.interval, allowOffsetMillis: isDefinedAndNotNull(value?.interval?.offsetMillis)} |
||||
|
} |
||||
|
this.entityAggregationConfiguration.patchValue(data, {emitEvent: false}); |
||||
|
this.checkAggIntervalType(this.entityAggregationConfiguration.get('interval.type').value); |
||||
|
this.checkIntervalDuration(this.entityAggregationConfiguration.get('interval.allowOffsetMillis').value); |
||||
|
this.checkWatermark(this.entityAggregationConfiguration.get('allowWatermark').value); |
||||
|
setTimeout(() => { |
||||
|
this.entityAggregationConfiguration.get('arguments').updateValueAndValidity({onlySelf: true}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
registerOnChange(fn: (config: CalculatedFieldEntityAggregationConfiguration) => void): void { |
||||
|
this.propagateChange = fn; |
||||
|
} |
||||
|
|
||||
|
registerOnTouched(_: any): void { } |
||||
|
|
||||
|
setDisabledState(isDisabled: boolean): void { |
||||
|
if (isDisabled) { |
||||
|
this.entityAggregationConfiguration.disable({emitEvent: false}); |
||||
|
} else { |
||||
|
this.entityAggregationConfiguration.enable({emitEvent: false}); |
||||
|
this.checkAggIntervalType(this.entityAggregationConfiguration.get('interval.type').value); |
||||
|
this.checkIntervalDuration(this.entityAggregationConfiguration.get('interval.allowOffsetMillis').value); |
||||
|
this.checkWatermark(this.entityAggregationConfiguration.get('allowWatermark').value); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private updatedModel(value: CalculatedFieldEntityAggregationConfigurationValue): void { |
||||
|
value.type = CalculatedFieldType.ENTITY_AGGREGATION; |
||||
|
if (!value.interval.allowOffsetMillis) { |
||||
|
delete value.interval.offsetMillis; |
||||
|
} |
||||
|
delete value.interval.offsetMillis; |
||||
|
if (!value.allowWatermark) { |
||||
|
delete value.watermark; |
||||
|
} |
||||
|
delete value.allowWatermark; |
||||
|
this.propagateChange(value); |
||||
|
} |
||||
|
|
||||
|
private checkAggIntervalType(type: AggIntervalType) { |
||||
|
if (type === AggIntervalType.CUSTOM) { |
||||
|
this.entityAggregationConfiguration.get('interval.multiplier').enable({emitEvent: false}); |
||||
|
} else { |
||||
|
this.entityAggregationConfiguration.get('interval.multiplier').disable({emitEvent: false}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private checkIntervalDuration(allow: boolean) { |
||||
|
if (allow) { |
||||
|
this.entityAggregationConfiguration.get('interval.offsetMillis').enable({emitEvent: false}); |
||||
|
} else { |
||||
|
this.entityAggregationConfiguration.get('interval.offsetMillis').disable({emitEvent: false}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private checkWatermark(allow: boolean) { |
||||
|
if (allow) { |
||||
|
this.entityAggregationConfiguration.get('watermark').enable({emitEvent: false}); |
||||
|
} else { |
||||
|
this.entityAggregationConfiguration.get('watermark').disable({emitEvent: false}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,49 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2025 The Thingsboard Authors
|
||||
|
///
|
||||
|
/// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
/// you may not use this file except in compliance with the License.
|
||||
|
/// You may obtain a copy of the License at
|
||||
|
///
|
||||
|
/// http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
///
|
||||
|
/// Unless required by applicable law or agreed to in writing, software
|
||||
|
/// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
|
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
|
/// See the License for the specific language governing permissions and
|
||||
|
/// limitations under the License.
|
||||
|
///
|
||||
|
|
||||
|
import { NgModule } from '@angular/core'; |
||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { SharedModule } from '@shared/shared.module'; |
||||
|
import { |
||||
|
CalculatedFieldOutputModule |
||||
|
} from '@home/components/calculated-fields/components/output/calculated-field-output.module'; |
||||
|
import { |
||||
|
CalculatedFieldArgumentsTableModule |
||||
|
} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module'; |
||||
|
import { |
||||
|
EntityAggregationComponentComponent |
||||
|
} from '@home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component'; |
||||
|
import { |
||||
|
CalculatedFieldMetricsTableModule |
||||
|
} from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-table.module'; |
||||
|
|
||||
|
@NgModule({ |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
SharedModule, |
||||
|
CalculatedFieldOutputModule, |
||||
|
CalculatedFieldArgumentsTableModule, |
||||
|
CalculatedFieldMetricsTableModule, |
||||
|
], |
||||
|
declarations: [ |
||||
|
EntityAggregationComponentComponent, |
||||
|
], |
||||
|
exports: [ |
||||
|
EntityAggregationComponentComponent, |
||||
|
] |
||||
|
}) |
||||
|
export class EntityAggregationComponentModule { |
||||
|
} |
||||
@ -0,0 +1,27 @@ |
|||||
|
import { NgModule } from '@angular/core'; |
||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { SharedModule } from '@shared/shared.module'; |
||||
|
import { |
||||
|
CalculatedFieldMetricsTableComponent |
||||
|
} from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component'; |
||||
|
import { |
||||
|
CalculatedFieldMetricsPanelComponent |
||||
|
} from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component'; |
||||
|
|
||||
|
|
||||
|
@NgModule({ |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
SharedModule, |
||||
|
], |
||||
|
declarations: [ |
||||
|
CalculatedFieldMetricsTableComponent, |
||||
|
CalculatedFieldMetricsPanelComponent |
||||
|
], |
||||
|
exports: [ |
||||
|
CalculatedFieldMetricsTableComponent |
||||
|
] |
||||
|
}) |
||||
|
export class CalculatedFieldMetricsTableModule { |
||||
|
|
||||
|
} |
||||
Loading…
Reference in new issue