Merge pull request #9880 from rusikv/enhancement/rule-chain-selector-search

Added search to rule chain selector
This commit is contained in:
Igor Kulikov 2024-01-24 17:19:13 +02:00 committed by GitHub
commit de39bdee4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 293 additions and 57 deletions

View File

@ -34,7 +34,7 @@
*ngIf="!isImport"
[ruleChainType]="ruleChainType"
[disabled]="isDirtyValue"
[(ngModel)]="ruleChain.id.id"
[(ngModel)]="ruleChain"
(ngModelChange)="currentRuleChainIdChanged(ruleChain.id?.id)">
</tb-rule-chain-select>
</mat-toolbar>

View File

@ -0,0 +1,43 @@
<!--
Copyright © 2016-2024 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]="selectRuleChainGroup" class="mat-block">
<input matInput type="text" placeholder="{{ 'rulechain.search' | translate }}"
#ruleChainInput
formControlName="ruleChainInput"
(focusin)="onFocus()"
[matAutocomplete]="ruleChainAutocomplete">
<mat-icon matSuffix>search</mat-icon>
<mat-autocomplete class="tb-autocomplete tb-rule-chain-search"
#ruleChainAutocomplete="matAutocomplete"
(optionSelected)="selected($event)">
<mat-option *ngFor="let ruleChain of filteredRuleChains | async"
[class.tb-selected-option]="ruleChainId === ruleChain.id.id"
[value]="ruleChain">
<mat-icon *ngIf="ruleChainId === ruleChain.id.id">check</mat-icon>
<span [innerHTML]="ruleChain.name | highlight:searchText:true"></span>
</mat-option>
<mat-option *ngIf="!(filteredRuleChains | async)?.length" [value]="null" class="tb-not-found">
<div class="tb-not-found-content" (click)="$event.stopPropagation()">
<span>
{{ 'rulechain.no-rulechains-matching' | translate : {entity: searchText} }}
</span>
</div>
</mat-option>
</mat-autocomplete>
</mat-form-field>

View File

@ -0,0 +1,40 @@
/**
* Copyright © 2016-2024 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 "../../../../scss/constants";
:host {
width: 100%;
}
::ng-deep {
.tb-autocomplete.tb-rule-chain-search {
.mat-mdc-option.tb-selected-option {
display: flex;
flex-direction: row-reverse;
background-color: rgba(0, 0, 0, .04);
mat-icon {
margin-right: 0;
margin-left: 16px;
color: $tb-primary-color;
}
.mdc-list-item__primary-text {
color: $tb-primary-color;
}
}
}
}

View File

@ -0,0 +1,126 @@
///
/// Copyright © 2016-2024 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 { AfterViewInit, Component, ElementRef, Inject, InjectionToken, ViewChild } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, map, share, startWith, switchMap } from 'rxjs/operators';
import { PageLink } from '@shared/models/page/page-link';
import { Direction } from '@shared/models/page/sort-order';
import { emptyPageData, PageData } from '@shared/models/page/page-data';
import { OverlayRef } from '@angular/cdk/overlay';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { RuleChain, RuleChainType } from '@shared/models/rule-chain.models';
import { RuleChainService } from '@core/http/rule-chain.service';
export const RULE_CHAIN_SELECT_PANEL_DATA = new InjectionToken<any>('RuleChainSelectPanelData');
export interface RuleChainSelectPanelData {
ruleChainId: string | null;
ruleChainType: RuleChainType;
}
@Component({
selector: 'tb-rule-chain-select-panel',
templateUrl: './rule-chain-select-panel.component.html',
styleUrls: ['./rule-chain-select-panel.component.scss']
})
export class RuleChainSelectPanelComponent implements AfterViewInit {
ruleChainId: string;
private readonly ruleChainType: RuleChainType;
selectRuleChainGroup: FormGroup;
@ViewChild('ruleChainInput', {static: true}) userInput: ElementRef;
filteredRuleChains: Observable<Array<RuleChain>>;
searchText = '';
ruleChainSelected = false;
result?: RuleChain;
private dirty = false;
constructor(@Inject(RULE_CHAIN_SELECT_PANEL_DATA) public data: RuleChainSelectPanelData,
private overlayRef: OverlayRef,
private fb: FormBuilder,
private ruleChainService: RuleChainService) {
this.ruleChainId = data.ruleChainId;
this.ruleChainType = data.ruleChainType;
this.selectRuleChainGroup = this.fb.group({
ruleChainInput: ['', {nonNullable: true}]
});
this.filteredRuleChains = this.selectRuleChainGroup.get('ruleChainInput').valueChanges
.pipe(
debounceTime(150),
startWith(''),
distinctUntilChanged((a: string, b: string) => a.trim() === b.trim()),
switchMap(name => this.fetchRuleChains(name)),
share()
);
}
ngAfterViewInit() {
setTimeout(() => {
this.userInput.nativeElement.focus();
}, 0);
}
selected(event: MatAutocompleteSelectedEvent): void {
this.clear();
this.ruleChainSelected = true;
if (event.option.value?.id) {
this.result = event.option.value;
}
this.overlayRef.dispose();
}
private fetchRuleChains(searchText?: string): Observable<Array<RuleChain>> {
this.searchText = searchText;
const pageLink = new PageLink(50, 0, searchText, {
property: 'name',
direction: Direction.ASC
});
return this.getRuleChains(pageLink)
.pipe(
catchError(() => of(emptyPageData<RuleChain>())),
map(pageData => pageData.data)
);
}
onFocus(): void {
if (!this.dirty) {
this.selectRuleChainGroup.get('ruleChainInput').updateValueAndValidity({onlySelf: true});
this.dirty = true;
}
}
clear() {
this.selectRuleChainGroup.get('ruleChainInput').patchValue('', {emitEvent: true});
setTimeout(() => {
this.userInput.nativeElement.blur();
this.userInput.nativeElement.focus();
}, 0);
}
private getRuleChains(pageLink: PageLink): Observable<PageData<RuleChain>> {
return this.ruleChainService.getRuleChains(pageLink, this.ruleChainType, {ignoreLoading: true});
}
}

View File

@ -15,17 +15,12 @@
limitations under the License.
-->
<mat-form-field class="tb-rule-select" subscriptSizing="dynamic">
<mat-form-field class="tb-rule-select" subscriptSizing="dynamic" appearance="fill"
[class.cursor-pointer]="!disabled"
(click)="openRuleChainSelectPanel($event)">
<mat-icon matPrefix>settings_ethernet</mat-icon>
<mat-select fxFlex
class="tb-rule-chain-select"
hideSingleSelectionIndicator="false"
[required]="required"
[disabled]="disabled"
[(ngModel)]="ruleChainId"
(ngModelChange)="ruleChainIdChanged()">
<mat-option *ngFor="let ruleChain of ruleChains$ | async" [value]="ruleChain.id.id">
{{ruleChain.name}}
</mat-option>
</mat-select>
<input matInput readonly disabled
[required]="required"
[value]="ruleChain?.name">
<mat-icon [class.disabled]="disabled || panelOpened" matSuffix>arrow_drop_down</mat-icon>
</mat-form-field>

View File

@ -19,19 +19,19 @@
padding: 0;
.tb-rule-select {
width: 100%;
}
.tb-rule-chain-select {
display: flex;
min-height: 100%;
pointer-events: all;
}
}
:host ::ng-deep {
.mat-mdc-form-field.tb-rule-select .mdc-text-field {
.mat-mdc-form-field-infix {
min-height: 48px;
padding: 12px 0;
input {
color: #fff;
cursor: pointer;
text-overflow: ellipsis;
pointer-events: none;
&:disabled {
cursor: unset;
}
}
}
.disabled {
opacity: 0.5;
}
}

View File

@ -14,20 +14,19 @@
/// limitations under the License.
///
import { Component, forwardRef, Input, OnInit } from '@angular/core';
import { Component, forwardRef, Injector, Input, StaticProvider, ViewContainerRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Observable } from 'rxjs';
import { PageLink } from '@shared/models/page/page-link';
import { map, share } from 'rxjs/operators';
import { PageData } from '@shared/models/page/page-data';
import { Store } from '@ngrx/store';
import { AppState } from '@app/core/core.state';
import { TooltipPosition } from '@angular/material/tooltip';
import { RuleChain, RuleChainType } from '@shared/models/rule-chain.models';
import { RuleChainService } from '@core/http/rule-chain.service';
import { isDefinedAndNotNull } from '@core/utils';
import { coerceBoolean } from '@shared/decorators/coercion';
import { Direction } from '@shared/models/page/sort-order';
import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay';
import {
RULE_CHAIN_SELECT_PANEL_DATA, RuleChainSelectPanelComponent,
RuleChainSelectPanelData
} from '@shared/components/rule-chain/rule-chain-select-panel.component';
import { ComponentPortal } from '@angular/cdk/portal';
import { POSITION_MAP } from '@shared/models/overlay.models';
@Component({
selector: 'tb-rule-chain-select',
@ -39,7 +38,7 @@ import { Direction } from '@shared/models/page/sort-order';
multi: true
}]
})
export class RuleChainSelectComponent implements ControlValueAccessor, OnInit {
export class RuleChainSelectComponent implements ControlValueAccessor {
@Input()
tooltipPosition: TooltipPosition = 'above';
@ -55,25 +54,14 @@ export class RuleChainSelectComponent implements ControlValueAccessor, OnInit {
@Input()
ruleChainType: RuleChainType = RuleChainType.CORE;
ruleChains$: Observable<Array<RuleChain>>;
ruleChain: RuleChain | null;
ruleChainId: string | null;
panelOpened = false;
private propagateChange = (v: any) => { };
constructor(private ruleChainService: RuleChainService) {
}
ngOnInit() {
const pageLink = new PageLink(1024, 0, null, {
property: 'name',
direction: Direction.ASC
});
this.ruleChains$ = this.getRuleChains(pageLink).pipe(
map((pageData) => pageData.data),
share()
);
constructor(private overlay: Overlay,
private viewContainerRef: ViewContainerRef) {
}
registerOnChange(fn: any): void {
@ -83,14 +71,13 @@ export class RuleChainSelectComponent implements ControlValueAccessor, OnInit {
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
writeValue(value: string | null): void {
writeValue(value: RuleChain): void {
if (isDefinedAndNotNull(value)) {
this.ruleChainId = value;
this.ruleChain = value;
}
}
@ -98,12 +85,54 @@ export class RuleChainSelectComponent implements ControlValueAccessor, OnInit {
this.updateView();
}
private updateView() {
this.propagateChange(this.ruleChainId);
openRuleChainSelectPanel($event: Event) {
if ($event) {
$event.stopPropagation();
}
if (!this.disabled) {
const target = $event.currentTarget;
const config = new OverlayConfig({
panelClass: 'tb-filter-panel',
backdropClass: 'cdk-overlay-transparent-backdrop',
hasBackdrop: true,
width: (target as HTMLElement).offsetWidth
});
config.positionStrategy = this.overlay.position()
.flexibleConnectedTo(target as HTMLElement)
.withPositions([POSITION_MAP.bottom]);
const overlayRef = this.overlay.create(config);
overlayRef.backdropClick().subscribe(() => {
overlayRef.dispose();
});
const providers: StaticProvider[] = [
{
provide: RULE_CHAIN_SELECT_PANEL_DATA,
useValue: {
ruleChainId: this.ruleChain.id?.id,
ruleChainType: this.ruleChainType
} as RuleChainSelectPanelData
},
{
provide: OverlayRef,
useValue: overlayRef
}
];
const injector = Injector.create({parent: this.viewContainerRef.injector, providers});
const componentRef = overlayRef.attach(new ComponentPortal(RuleChainSelectPanelComponent,
this.viewContainerRef, injector));
this.panelOpened = true;
componentRef.onDestroy(() => {
this.panelOpened = false;
if (componentRef.instance.ruleChainSelected) {
this.ruleChain = componentRef.instance.result;
this.updateView();
}
});
}
}
private getRuleChains(pageLink: PageLink): Observable<PageData<RuleChain>> {
return this.ruleChainService.getRuleChains(pageLink, this.ruleChainType, {ignoreLoading: true});
private updateView() {
this.propagateChange(this.ruleChain);
}
}

View File

@ -216,6 +216,7 @@ import { GalleryImageInputComponent } from '@shared/components/image/gallery-ima
import { MultipleGalleryImageInputComponent } from '@shared/components/image/multiple-gallery-image-input.component';
import { EmbedImageDialogComponent } from '@shared/components/image/embed-image-dialog.component';
import { ImageGalleryDialogComponent } from '@shared/components/image/image-gallery-dialog.component';
import { RuleChainSelectPanelComponent } from '@shared/components/rule-chain/rule-chain-select-panel.component';
export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
return markedOptionsService;
@ -397,6 +398,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
StringAutocompleteComponent,
MaterialIconsComponent,
RuleChainSelectComponent,
RuleChainSelectPanelComponent,
TbIconComponent,
HintTooltipIconComponent,
ImportDialogComponent,
@ -648,6 +650,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
StringAutocompleteComponent,
MaterialIconsComponent,
RuleChainSelectComponent,
RuleChainSelectPanelComponent,
TbIconComponent,
HintTooltipIconComponent,
ImportDialogComponent,