UI: Change notification request support send without template

This commit is contained in:
Vladyslav_Prykhodko 2023-02-20 11:30:26 +02:00
parent 7e4d92e8eb
commit 6945b96e3c
12 changed files with 362 additions and 20 deletions

View File

@ -1,3 +1,19 @@
///
/// Copyright © 2016-2023 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 { CmdWrapper, WsService, WsSubscriber } from '@shared/models/websocket/websocket.models'; import { CmdWrapper, WsService, WsSubscriber } from '@shared/models/websocket/websocket.models';
import { select, Store } from '@ngrx/store'; import { select, Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';

View File

@ -16,8 +16,8 @@
import { Component, ElementRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core'; import { Component, ElementRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core';
import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';
import { Observable, of } from 'rxjs'; import { Observable, of, ReplaySubject } from 'rxjs';
import { debounceTime, map, publishReplay, refCount, share, switchMap, tap } from 'rxjs/operators'; import { debounceTime, map, share, switchMap, tap } from 'rxjs/operators';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
@ -201,8 +201,12 @@ export class SlackConversationAutocompleteComponent implements ControlValueAcces
fetchObservable = of([]); fetchObservable = of([]);
} }
this.slackConversetionFetchObservable$ = fetchObservable.pipe( this.slackConversetionFetchObservable$ = fetchObservable.pipe(
publishReplay(1), share({
refCount() connector: () => new ReplaySubject(1),
resetOnError: false,
resetOnComplete: false,
resetOnRefCountZero: false
})
); );
} }
return this.slackConversetionFetchObservable$; return this.slackConversetionFetchObservable$;

View File

@ -36,7 +36,7 @@
</ng-template> </ng-template>
<mat-step [stepControl]="notificationRequestForm"> <mat-step [stepControl]="notificationRequestForm">
<ng-template matStepLabel>{{ 'notification.compose' | translate }}</ng-template> <ng-template matStepLabel>{{ 'notification.compose' | translate }}</ng-template>
<form [formGroup]="notificationRequestForm" style="padding-bottom: 16px;"> <form [formGroup]="notificationRequestForm">
<div fxLayout="row" fxLayoutAlign="center"> <div fxLayout="row" fxLayoutAlign="center">
<mat-button-toggle-group class="tb-notification-use-template-toggle-group" <mat-button-toggle-group class="tb-notification-use-template-toggle-group"
style="width: 320px;" style="width: 320px;"

View File

@ -17,7 +17,7 @@
@import "../../../../../../theme"; @import "../../../../../../theme";
:host-context(.tb-fullscreen-dialog .mat-dialog-container) { :host-context(.tb-fullscreen-dialog .mat-dialog-container) {
width: 640px; width: 800px;
max-height: 100vh; max-height: 100vh;
.tb-form-fields { .tb-form-fields {
@ -169,7 +169,7 @@
} }
} }
.mat-horizontal-content-container { .mat-horizontal-content-container {
height: 540px; height: 600px;
max-height: 100%; max-height: 100%;
width: 100%;; width: 100%;;
overflow-y: auto; overflow-y: auto;
@ -241,7 +241,7 @@
.mat-form-field { .mat-form-field {
.mat-form-field-wrapper { .mat-form-field-wrapper {
padding-bottom: 0px; padding-bottom: 0;
.mat-form-field-underline { .mat-form-field-underline {
position: initial !important; position: initial !important;

View File

@ -29,7 +29,7 @@ import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { NotificationService } from '@core/http/notification.service'; import { NotificationService } from '@core/http/notification.service';
import { deepTrim } from '@core/utils'; import { deepTrim, guid } from '@core/utils';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { EntityType } from '@shared/models/entity-type.models'; import { EntityType } from '@shared/models/entity-type.models';
import { BreakpointObserver } from '@angular/cdk/layout'; import { BreakpointObserver } from '@angular/cdk/layout';
@ -88,8 +88,8 @@ export class RequestNotificationDialogComponent extends
.pipe(map(({matches}) => matches ? 'horizontal' : 'vertical')); .pipe(map(({matches}) => matches ? 'horizontal' : 'vertical'));
this.notificationRequestForm = this.fb.group({ this.notificationRequestForm = this.fb.group({
useTemplate: [true], useTemplate: [false],
templateId: [null, Validators.required], templateId: [{value: null, disabled: true}, Validators.required],
targets: [null, Validators.required], targets: [null, Validators.required],
template: this.templateNotificationForm, template: this.templateNotificationForm,
additionalConfig: this.fb.group({ additionalConfig: this.fb.group({
@ -99,9 +99,7 @@ export class RequestNotificationDialogComponent extends
}) })
}); });
this.notificationRequestForm.get('template').disable({emitEvent: false}); this.notificationRequestForm.get('template.name').setValue(guid())
this.notificationRequestForm.get('template.name').removeValidators(Validators.required);
this.notificationRequestForm.get('template.name').updateValueAndValidity({emitEvent: false});
this.notificationRequestForm.get('useTemplate').valueChanges.pipe( this.notificationRequestForm.get('useTemplate').valueChanges.pipe(
takeUntil(this.destroy$) takeUntil(this.destroy$)
@ -203,8 +201,10 @@ export class RequestNotificationDialogComponent extends
private get notificationFormValue(): NotificationRequest { private get notificationFormValue(): NotificationRequest {
const formValue = deepTrim(this.notificationRequestForm.value); const formValue = deepTrim(this.notificationRequestForm.value);
if (!formValue.useTemplate) {
formValue.template = super.getNotificationTemplateValue();
}
delete formValue.useTemplate; delete formValue.useTemplate;
delete formValue.template;
let delay = 0; let delay = 0;
if (formValue.additionalConfig.enabled) { if (formValue.additionalConfig.enabled) {
delay = (this.notificationRequestForm.value.additionalConfig.time.valueOf() - this.minDate().valueOf()) / 1000; delay = (this.notificationRequestForm.value.additionalConfig.time.valueOf() - this.minDate().valueOf()) / 1000;

View File

@ -30,6 +30,7 @@ import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { MatDialogRef } from '@angular/material/dialog'; import { MatDialogRef } from '@angular/material/dialog';
import { deepClone, deepTrim } from '@core/utils';
@Directive() @Directive()
// tslint:disable-next-line:directive-class-suffix // tslint:disable-next-line:directive-class-suffix
@ -161,7 +162,7 @@ export abstract class TemplateConfiguration<T, R = any> extends DialogComponent<
} }
protected getNotificationTemplateValue(): NotificationTemplate { protected getNotificationTemplateValue(): NotificationTemplate {
const template: NotificationTemplate = this.templateNotificationForm.value; const template: NotificationTemplate = deepClone(this.templateNotificationForm.value);
this.notificationDeliveryMethods.forEach(method => { this.notificationDeliveryMethods.forEach(method => {
if (template.configuration.deliveryMethodsTemplates[method].enabled) { if (template.configuration.deliveryMethodsTemplates[method].enabled) {
Object.assign(template.configuration.deliveryMethodsTemplates[method], this.deliveryMethodFormsMap.get(method).value, {method}); Object.assign(template.configuration.deliveryMethodsTemplates[method], this.deliveryMethodFormsMap.get(method).value, {method});
@ -169,6 +170,6 @@ export abstract class TemplateConfiguration<T, R = any> extends DialogComponent<
delete template.configuration.deliveryMethodsTemplates[method]; delete template.configuration.deliveryMethodsTemplates[method];
} }
}); });
return template; return deepTrim(template);
} }
} }

View File

@ -27,7 +27,7 @@ import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { FormBuilder, FormGroup } from '@angular/forms'; import { FormBuilder, FormGroup } from '@angular/forms';
import { NotificationService } from '@core/http/notification.service'; import { NotificationService } from '@core/http/notification.service';
import { deepClone, deepTrim, isDefinedAndNotNull } from '@core/utils'; import { deepClone, isDefinedAndNotNull } from '@core/utils';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { StepperOrientation, StepperSelectionEvent } from '@angular/cdk/stepper'; import { StepperOrientation, StepperSelectionEvent } from '@angular/cdk/stepper';
@ -155,7 +155,7 @@ export class TemplateNotificationDialogComponent
if (this.templateNotification && !this.data.isCopy) { if (this.templateNotification && !this.data.isCopy) {
template = {...this.templateNotification, ...template}; template = {...this.templateNotification, ...template};
} }
this.notificationService.saveNotificationTemplate(deepTrim(template)).subscribe( this.notificationService.saveNotificationTemplate(template).subscribe(
(target) => this.dialogRef.close(target) (target) => this.dialogRef.close(target)
); );
} }

View File

@ -0,0 +1,72 @@
<!--
Copyright © 2016-2023 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.
-->
<mat-form-field [formGroup]="selectDashboardStateFormGroup" class="mat-block" [floatLabel]="floatLabel">
<input matInput type="text" placeholder="{{ placeholder || ('widget-action.target-dashboard-state' | translate) }}"
#dashboardStateInput
formControlName="dashboardStateId"
[required]="required"
[matAutocomplete]="dashboardStateAutocomplete">
<button *ngIf="selectDashboardStateFormGroup.get('dashboardStateId').value"
type="button"
matSuffix mat-icon-button aria-label="Clear"
(click)="clear()">
<mat-icon class="material-icons">close</mat-icon>
</button>
<mat-autocomplete
class="tb-autocomplete"
#dashboardStateAutocomplete="matAutocomplete">
<mat-option *ngFor="let state of filteredStatesDashboard$ | async" [value]="state">
<span [innerHTML]="state | highlight:searchText"></span>
</mat-option>
</mat-autocomplete>
<mat-error *ngIf="selectDashboardStateFormGroup.get('dashboardStateId').hasError('required')">
{{ 'widget-action.target-dashboard-state-required' | translate }}
</mat-error>
</mat-form-field>
<!-- <input matInput type="text" placeholder="{{ placeholder || ('dashboard.dashboard' | translate) }}"-->
<!-- #dashboardInput-->
<!-- formControlName="dashboard"-->
<!-- (focusin)="onFocus()"-->
<!-- [required]="required"-->
<!-- [matAutocomplete]="dashboardAutocomplete">-->
<!-- <button *ngIf="selectDashboardStateFormGroup.get('dashboard').value && !disabled"-->
<!-- type="button"-->
<!-- matSuffix mat-button mat-icon-button aria-label="Clear"-->
<!-- (click)="clear()">-->
<!-- <mat-icon class="material-icons">close</mat-icon>-->
<!-- </button>-->
<!-- <mat-autocomplete-->
<!-- class="tb-autocomplete"-->
<!-- #dashboardAutocomplete="matAutocomplete"-->
<!-- [displayWith]="displayDashboardFn">-->
<!-- <mat-option *ngFor="let dashboard of filteredDashboards | async" [value]="dashboard">-->
<!-- <span [innerHTML]="dashboard.title | highlight:searchText"></span>-->
<!-- </mat-option>-->
<!-- <mat-option *ngIf="!(filteredDashboards | async)?.length" [value]="null">-->
<!-- <span>-->
<!-- {{ translate.get('dashboard.no-dashboards-matching', {entity: searchText}) | async }}-->
<!-- </span>-->
<!-- </mat-option>-->
<!-- </mat-autocomplete>-->
<!-- <mat-error>-->
<!-- <ng-content select="[tb-error]"></ng-content>-->
<!-- </mat-error>-->
<!-- <mat-hint>-->
<!-- <ng-content select="[tb-hint]"></ng-content>-->
<!-- </mat-hint>-->
<!--</mat-form-field>-->

View File

@ -0,0 +1,229 @@
///
/// Copyright © 2016-2023 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, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core';
import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';
import { Observable, of, ReplaySubject } from 'rxjs';
import { debounceTime, distinctUntilChanged, map, share, switchMap, tap } from 'rxjs/operators';
import { Dashboard, DashboardInfo } from '@app/shared/models/dashboard.models';
import { DashboardService } from '@core/http/dashboard.service';
import { Store } from '@ngrx/store';
import { AppState } from '@app/core/core.state';
import { TranslateService } from '@ngx-translate/core';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { FloatLabelType } from '@angular/material/form-field';
import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
@Component({
selector: 'tb-dashboard-state-autocomplete',
templateUrl: './dashboard-state-autocomplete.component.html',
styleUrls: [],
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DashboardStateAutocompleteComponent),
multi: true
}]
})
export class DashboardStateAutocompleteComponent implements ControlValueAccessor, OnInit {
private dirty = false;
private modelValue: string;
private latestDashboardStates: Array<string> = null;
private dashboardStatesFetchObservable$: Observable<Array<string>> = null;
private propagateChange = (v: any) => { };
@Input()
placeholder: string;
@Input()
floatLabel: FloatLabelType = 'auto';
private requiredValue: boolean;
get required(): boolean {
return this.requiredValue;
}
@Input()
set required(value: boolean) {
this.requiredValue = coerceBooleanProperty(value);
}
@Input()
disabled: boolean;
private dashboardIdValue: string;
get dashboardId(): string {
return this.dashboardIdValue;
}
set dashboardId(value: string) {
this.dashboardIdValue = value;
this.clearDashboardStateCache();
this.searchText = '';
this.selectDashboardStateFormGroup.get('dashboardStateId').patchValue('', {emitEvent: false});
this.dirty = true;
}
@ViewChild('dashboardStateInput', {static: true}) dashboardStateInput: ElementRef;
filteredStatesDashboard$: Observable<Array<string>>;
searchText = '';
selectDashboardStateFormGroup = this.fb.group({
dashboardStateId: [null]
});
constructor(private store: Store<AppState>,
public translate: TranslateService,
private dashboardService: DashboardService,
private dashboardUtils: DashboardUtilsService,
private fb: FormBuilder) {
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
ngOnInit() {
if (this.required) {
this.selectDashboardStateFormGroup.get('dashboardStateId').addValidators(Validators.required);
this.selectDashboardStateFormGroup.get('dashboardStateId').updateValueAndValidity({emitEvent: false});
}
this.filteredStatesDashboard$ = this.selectDashboardStateFormGroup.get('dashboardStateId').valueChanges
.pipe(
debounceTime(150),
tap(value => {
let modelValue;
if (!value || !this.latestDashboardStates.includes(value)) {
modelValue = null;
} else {
modelValue = value;
}
this.updateView(modelValue);
}),
distinctUntilChanged(),
switchMap(name => this.fetchDashboardStates(name) ),
share()
);
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.disabled) {
this.selectDashboardStateFormGroup.disable({emitEvent: false});
} else {
this.selectDashboardStateFormGroup.enable({emitEvent: false});
}
}
writeValue(value: string | null): void {
this.searchText = '';
if (value != null) {
this.modelValue = value;
this.selectDashboardStateFormGroup.get('dashboardStateId').patchValue(value, {emitEvent: false});
} else {
this.modelValue = null;
this.selectDashboardStateFormGroup.get('dashboardStateId').patchValue('', {emitEvent: false});
}
this.dirty = true;
}
private updateView(value: string | null) {
if (this.modelValue !== value) {
this.modelValue = value;
this.propagateChange(this.modelValue);
}
}
displayDashboardFn(dashboard?: DashboardInfo): string | undefined {
return dashboard ? dashboard.title : undefined;
}
onFocus() {
if (this.dirty) {
this.selectDashboardStateFormGroup.get('dashboard').updateValueAndValidity({onlySelf: true});
this.dirty = false;
}
}
clear(value = '') {
this.dashboardStateInput.nativeElement.value = value;
this.selectDashboardStateFormGroup.get('dashboardStateId').patchValue(value, {emitEvent: true});
setTimeout(() => {
this.dashboardStateInput.nativeElement.blur();
this.dashboardStateInput.nativeElement.focus();
}, 0);
}
private fetchDashboardStates(searchText?: string): Observable<Array<string>> {
if (this.searchText !== searchText || this.latestDashboardStates === null) {
this.searchText = searchText;
const slackConversationFilter = this.createFilterForDashboardState(this.searchText);
return this.getDashboardStatesById().pipe(
map(name => name.filter(slackConversationFilter)),
tap(res => this.latestDashboardStates = res)
);
}
return of(this.latestDashboardStates);
}
private getDashboardStatesById() {
if (this.dashboardStatesFetchObservable$ === null) {
let fetchObservable: Observable<Array<string>>;
if (this.dashboardId) {
fetchObservable = this.dashboardService.getDashboard(this.dashboardId, {ignoreLoading: true}).pipe(
map((dashboard: Dashboard) => {
if (dashboard) {
dashboard = this.dashboardUtils.validateAndUpdateDashboard(dashboard);
const states = dashboard.configuration.states;
return Object.keys(states);
} else {
return [];
}
})
);
} else {
fetchObservable = of([]);
}
this.dashboardStatesFetchObservable$ = fetchObservable.pipe(
share({
connector: () => new ReplaySubject(1),
resetOnError: false,
resetOnComplete: false,
resetOnRefCountZero: false
})
);
}
return this.dashboardStatesFetchObservable$;
}
private createFilterForDashboardState(query: string): (stateId: string) => boolean {
const lowercaseQuery = query.toLowerCase();
return stateId => stateId.toLowerCase().indexOf(lowercaseQuery) === 0;
}
private clearDashboardStateCache(): void {
this.latestDashboardStates = null;
this.dashboardStatesFetchObservable$ = null;
}
}

View File

@ -50,7 +50,8 @@ export interface NotificationInfo {
export interface NotificationRequest extends Omit<BaseData<NotificationRequestId>, 'label'> { export interface NotificationRequest extends Omit<BaseData<NotificationRequestId>, 'label'> {
tenantId?: TenantId; tenantId?: TenantId;
targets: Array<string>; targets: Array<string>;
templateId: NotificationTemplateId; templateId?: NotificationTemplateId;
template?: NotificationTemplate;
info?: NotificationInfo; info?: NotificationInfo;
deliveryMethods: Array<NotificationDeliveryMethod>; deliveryMethods: Array<NotificationDeliveryMethod>;
originatorEntityId: EntityId; originatorEntityId: EntityId;

View File

@ -1,3 +1,19 @@
///
/// Copyright © 2016-2023 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 { NgZone } from '@angular/core'; import { NgZone } from '@angular/core';
import { WebsocketCmd } from '@shared/models/telemetry/telemetry.models'; import { WebsocketCmd } from '@shared/models/telemetry/telemetry.models';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';

View File

@ -86,6 +86,7 @@ import { MarkdownEditorComponent } from '@shared/components/markdown-editor.comp
import { FullscreenDirective } from '@shared/components/fullscreen.directive'; import { FullscreenDirective } from '@shared/components/fullscreen.directive';
import { HighlightPipe } from '@shared/pipe/highlight.pipe'; import { HighlightPipe } from '@shared/pipe/highlight.pipe';
import { DashboardAutocompleteComponent } from '@shared/components/dashboard-autocomplete.component'; import { DashboardAutocompleteComponent } from '@shared/components/dashboard-autocomplete.component';
import { DashboardStateAutocompleteComponent } from '@shared/components/dashboard-state-autocomplete.component';
import { EntitySubTypeAutocompleteComponent } from '@shared/components/entity/entity-subtype-autocomplete.component'; import { EntitySubTypeAutocompleteComponent } from '@shared/components/entity/entity-subtype-autocomplete.component';
import { EntitySubTypeSelectComponent } from '@shared/components/entity/entity-subtype-select.component'; import { EntitySubTypeSelectComponent } from '@shared/components/entity/entity-subtype-select.component';
import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component'; import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component';
@ -243,6 +244,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
TimezoneSelectComponent, TimezoneSelectComponent,
ValueInputComponent, ValueInputComponent,
DashboardAutocompleteComponent, DashboardAutocompleteComponent,
DashboardStateAutocompleteComponent,
EntitySubTypeAutocompleteComponent, EntitySubTypeAutocompleteComponent,
EntitySubTypeSelectComponent, EntitySubTypeSelectComponent,
EntitySubTypeListComponent, EntitySubTypeListComponent,
@ -403,6 +405,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
DatetimeComponent, DatetimeComponent,
TimezoneSelectComponent, TimezoneSelectComponent,
DashboardAutocompleteComponent, DashboardAutocompleteComponent,
DashboardStateAutocompleteComponent,
EntitySubTypeAutocompleteComponent, EntitySubTypeAutocompleteComponent,
EntitySubTypeSelectComponent, EntitySubTypeSelectComponent,
EntitySubTypeListComponent, EntitySubTypeListComponent,