diff --git a/ui/STYLEGUIDE.md b/ui/STYLEGUIDE.md index 59574173fb..2560fccaf0 100644 --- a/ui/STYLEGUIDE.md +++ b/ui/STYLEGUIDE.md @@ -102,6 +102,68 @@ To render smaller inputs in a dense layout, assign the CSS class `form-field-sma You can also define an optional tooltip which is shown above the label. +#### Search Select + +Use `sp-search-select` when users need to select one or more items from a searchable list, such as datasets, labels, sites, asset types, users, roles or groups. + +Keep data loading, persistence and feature-specific actions outside the component. For example, actions such as `Manage Labels` or refresh buttons should remain in the parent view, usually in the surrounding `sp-form-field` actions. + +Single-select example: + +```html + + +``` + +Multi-select example: + +```html + + +``` + +The component keeps the selected value as the full object. It displays items by convention using `label`, `name`, `measureName`, `title`, `email`, `groupName`, `value`, `filename`, `assetName`, `_id` or `id`, and filters client-side by the displayed value. + +Objects with a `color` property are rendered as small `sp-label` badges in both the selected value and the dropdown. This covers labels without adding label-specific inputs to the component: + +```html + + +``` + +Use optional templates only when the default text or colored-label rendering is not enough: + +```html + + + {{ user.email }} ({{ user.principalId }}) + + + {{ user.email }} + +``` + +Do not add feature-specific inputs such as label color keys, server-side search, action slots or custom comparison functions unless there is a concrete reusable need. Prefer the minimal API and keep feature behavior in the parent component. + #### Label Use form labels to ensure a consistent layout of forms and labels. diff --git a/ui/deployment/i18n/de.json b/ui/deployment/i18n/de.json index f132229e81..726f1dbeff 100644 --- a/ui/deployment/i18n/de.json +++ b/ui/deployment/i18n/de.json @@ -73,6 +73,7 @@ "Add node": "Knoten hinzufügen", "Add pipeline element": "Pipeline-Element hinzufügen", "Add role": "Rolle hinzufügen", + "Add service tag": "Service-Tag hinzufügen", "Add the current dashboard to an existing asset": "Das aktuelle Dashboard zu einem bestehenden Asset hinzufügen", "Add to Asset": "Zu Asset hinzufügen", "Add user": "Benutzer hinzufügen", @@ -749,6 +750,7 @@ "No more information": "Keine weiteren Informationen", "No nodes selected yet": "Noch keine Knoten ausgewählt", "No numeric or boolean fields available.": "Keine numerischen oder booleschen Felder verfügbar.", + "No results found": "Keine Ergebnisse gefunden", "No running adapters": "Keine laufenden Adapter", "No running pipeline elements": "Keine laufenden Pipeline-Elemente", "No table rows match the current filter.": "Keine Tabellenzeilen entsprechen dem aktuellen Filter.", diff --git a/ui/deployment/i18n/en.json b/ui/deployment/i18n/en.json index 675ee0f3a1..073f3b8872 100644 --- a/ui/deployment/i18n/en.json +++ b/ui/deployment/i18n/en.json @@ -73,6 +73,7 @@ "Add node": null, "Add pipeline element": null, "Add role": null, + "Add service tag": null, "Add the current dashboard to an existing asset": null, "Add to Asset": null, "Add user": null, @@ -749,6 +750,7 @@ "No more information": null, "No nodes selected yet": null, "No numeric or boolean fields available.": null, + "No results found": null, "No running adapters": null, "No running pipeline elements": null, "No table rows match the current filter.": null, diff --git a/ui/deployment/i18n/pl.json b/ui/deployment/i18n/pl.json index cc153b2023..0764b3d447 100644 --- a/ui/deployment/i18n/pl.json +++ b/ui/deployment/i18n/pl.json @@ -73,6 +73,7 @@ "Add node": "Dodaj węzeł", "Add pipeline element": "Dodaj element strumienia", "Add role": "Dodaj rolę", + "Add service tag": "Dodaj tag usługi", "Add the current dashboard to an existing asset": "Dodaj bieżący pulpit do istniejącego zasobu", "Add to Asset": "Dodaj do zasobu", "Add user": "Dodaj użytkownika", @@ -749,6 +750,7 @@ "No more information": "Brak dalszych informacji", "No nodes selected yet": "Nie wybrano jeszcze żadnych węzłów", "No numeric or boolean fields available.": "Brak dostępnych pól liczbowych lub logicznych.", + "No results found": "Nie znaleziono wyników", "No running adapters": "Brak uruchomionych adapterów", "No running pipeline elements": "Brak uruchomionych elementów strumienia", "No table rows match the current filter.": "Żaden wiersz tabeli nie pasuje do bieżącego filtra.", diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/search-select/search-select-template.directive.ts b/ui/projects/streampipes/shared-ui/src/lib/components/search-select/search-select-template.directive.ts new file mode 100644 index 0000000000..d3ed2f9a68 --- /dev/null +++ b/ui/projects/streampipes/shared-ui/src/lib/components/search-select/search-select-template.directive.ts @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 { Directive, TemplateRef, inject } from '@angular/core'; + +@Directive({ + selector: 'ng-template[spSearchSelectOption]', +}) +export class SearchSelectOptionTemplateDirective { + readonly templateRef = inject>(TemplateRef); +} + +@Directive({ + selector: 'ng-template[spSearchSelectChip]', +}) +export class SearchSelectChipTemplateDirective { + readonly templateRef = inject>(TemplateRef); +} diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/search-select/search-select.component.html b/ui/projects/streampipes/shared-ui/src/lib/components/search-select/search-select.component.html new file mode 100644 index 0000000000..bdf1e11189 --- /dev/null +++ b/ui/projects/streampipes/shared-ui/src/lib/components/search-select/search-select.component.html @@ -0,0 +1,141 @@ + + + + search + + @if (multiple()) { + + @for (item of selectedItems(); track trackItem($index, item)) { + @let color = itemColor(item); + @let contrastColor = itemContrastColor(item); + + @if (chipTemplate) { + + + } @else if (color) { + + + } @else { + {{ displayValue(item) }} + } + + + } + + + + } @else { + + } + + @if (selectedItems().length > 0 && !disabled()) { + + } + + + @for (item of filteredItems(); track trackItem($index, item)) { + @let color = itemColor(item); + + + @if (isSelected(item)) { + check + } + + @if (optionTemplate) { + + + } @else if (color) { + + + } @else { + {{ displayValue(item) }} + } + + } + + @if (filteredItems().length === 0) { + + {{ 'No results found' | translate }} + + } + + diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/search-select/search-select.component.scss b/ui/projects/streampipes/shared-ui/src/lib/components/search-select/search-select.component.scss new file mode 100644 index 0000000000..bb0168afdd --- /dev/null +++ b/ui/projects/streampipes/shared-ui/src/lib/components/search-select/search-select.component.scss @@ -0,0 +1,104 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + * + */ + +:host { + display: block; + width: 100%; +} + +mat-form-field { + width: 100%; +} + +.search-select-check-icon { + margin-right: var(--space-xs); + width: 1.125rem; +} + +.search-select-chip-colored { + background-color: transparent; + color: var(--sp-search-select-chip-fg); + min-height: auto; + padding: 0; + --mat-chip-container-height: auto; + --mat-chip-elevated-container-color: transparent; + --mat-chip-elevated-selected-container-color: transparent; + --mat-chip-flat-disabled-selected-container-color: transparent; + --mat-chip-outline-color: transparent; + --mat-chip-outline-width: 0; + --mat-chip-flat-selected-outline-width: 0; + --mat-chip-focus-outline-color: transparent; + --mat-chip-label-text-color: var(--sp-search-select-chip-fg); + --mat-chip-selected-label-text-color: var(--sp-search-select-chip-fg); + --mat-chip-with-trailing-icon-trailing-icon-color: var( + --sp-search-select-chip-fg + ); + --mat-chip-selected-trailing-icon-color: var(--sp-search-select-chip-fg); + --mdc-chip-elevated-container-color: transparent; + --mdc-chip-outline-color: transparent; + --mdc-chip-outline-width: 0; + --mdc-chip-focus-outline-color: transparent; + --mdc-chip-label-text-color: var(--sp-search-select-chip-fg); + --mdc-chip-with-trailing-icon-trailing-icon-color: var( + --sp-search-select-chip-fg + ); + --mdc-chip-with-trailing-icon-trailing-icon-hover-color: var( + --sp-search-select-chip-fg + ); + --mdc-chip-with-trailing-icon-trailing-icon-focus-color: var( + --sp-search-select-chip-fg + ); +} + +// Angular Material renders chip outlines inside component internals. Colored +// items use sp-label for the visible badge, so the Material wrapper stays flat. +:host ::ng-deep .search-select-chip-colored { + .mat-mdc-chip-focus-overlay { + display: none; + } + + .mdc-evolution-chip__cell, + .mdc-evolution-chip__action, + .mat-mdc-chip-action-label { + min-height: auto; + } + + .mdc-evolution-chip__cell--primary, + .mdc-evolution-chip__action--primary { + padding-left: 0; + padding-right: 0; + } + + .mdc-evolution-chip__action--primary::before { + border-color: transparent; + border-width: 0; + } + + .mdc-evolution-chip__cell--trailing, + .mdc-evolution-chip__action--trailing { + padding-left: var(--space-xs); + padding-right: 0; + } + + .mdc-evolution-chip__action, + .mdc-evolution-chip__text-label, + .mat-mdc-chip-action-label, + .mat-icon { + color: var(--sp-search-select-chip-fg); + } +} diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/search-select/search-select.component.ts b/ui/projects/streampipes/shared-ui/src/lib/components/search-select/search-select.component.ts new file mode 100644 index 0000000000..abf032049b --- /dev/null +++ b/ui/projects/streampipes/shared-ui/src/lib/components/search-select/search-select.component.ts @@ -0,0 +1,304 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 { + ChangeDetectionStrategy, + Component, + ContentChild, + ViewChild, + booleanAttribute, + computed, + inject, + input, + model, + signal, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { + MatAutocomplete, + MatAutocompleteSelectedEvent, + MatAutocompleteTrigger, +} from '@angular/material/autocomplete'; +import { + MatChipGrid, + MatChipRow, + MatChipsModule, +} from '@angular/material/chips'; +import { + MatFormField, + MatPrefix, + MatSuffix, +} from '@angular/material/form-field'; +import { MatIcon } from '@angular/material/icon'; +import { MatIconButton } from '@angular/material/button'; +import { MatInput } from '@angular/material/input'; +import { MatOption } from '@angular/material/core'; +import { TranslatePipe } from '@ngx-translate/core'; +import { + SearchSelectChipTemplateDirective, + SearchSelectOptionTemplateDirective, +} from './search-select-template.directive'; +import { SpColorizationService } from '../../services/colorization.service'; +import { SpLabelComponent } from '../sp-label/sp-label.component'; + +const DISPLAY_VALUE_FIELDS = [ + 'label', + 'name', + 'measureName', + 'title', + 'email', + 'groupName', + 'value', + 'filename', + 'assetName', + '_id', + 'id', +]; + +const IDENTITY_FIELDS = [ + '_id', + 'id', + 'measureName', + 'principalId', + 'groupId', + 'value', + 'filename', + 'assetName', +]; + +@Component({ + selector: 'sp-search-select', + templateUrl: './search-select.component.html', + styleUrls: ['./search-select.component.scss'], + imports: [ + MatAutocomplete, + MatAutocompleteTrigger, + MatChipGrid, + MatChipRow, + MatChipsModule, + MatFormField, + MatIcon, + MatIconButton, + MatInput, + MatOption, + MatPrefix, + MatSuffix, + NgTemplateOutlet, + SpLabelComponent, + TranslatePipe, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SearchSelectComponent { + private colorizationService = inject(SpColorizationService); + + readonly items = input([]); + readonly multiple = input(false); + readonly disabled = input(false, { transform: booleanAttribute }); + readonly placeholder = input(); + readonly dataCy = input(); + + readonly value = model(undefined); + + readonly searchText = signal(''); + readonly panelOpen = signal(false); + + @ContentChild(SearchSelectOptionTemplateDirective) + optionTemplate?: SearchSelectOptionTemplateDirective; + + @ContentChild(SearchSelectChipTemplateDirective) + chipTemplate?: SearchSelectChipTemplateDirective; + + @ViewChild(MatAutocompleteTrigger) + autocompleteTrigger?: MatAutocompleteTrigger; + + readonly selectedItems = computed(() => { + const value = this.value(); + + if (!this.multiple()) { + return value && !Array.isArray(value) ? [value] : []; + } + + return Array.isArray(value) ? value : []; + }); + + readonly selectedValue = computed(() => this.selectedItems()[0]); + + readonly selectedText = computed(() => { + const selected = this.selectedValue(); + return selected ? this.displayValue(selected) : ''; + }); + + readonly inputValue = computed(() => + this.panelOpen() || this.multiple() + ? this.searchText() + : this.selectedText(), + ); + + readonly placeholderText = computed(() => { + if (this.placeholder()) { + return this.placeholder(); + } + + return this.multiple() ? 'Add' : 'Select'; + }); + + readonly filteredItems = computed(() => { + const query = this.searchText().trim().toLowerCase(); + + if (!query) { + return this.items(); + } + + return this.items().filter(item => + this.displayValue(item).toLowerCase().includes(query), + ); + }); + + readonly autocompleteDisplayWith = (item: T): string => + this.displayValue(item); + + onInput(value: string): void { + this.searchText.set(value); + } + + onOpened(): void { + this.panelOpen.set(true); + this.searchText.set(''); + } + + onClosed(): void { + this.panelOpen.set(false); + this.searchText.set(''); + } + + onSelected(event: MatAutocompleteSelectedEvent): void { + const item = event.option.value as T; + + if (this.multiple()) { + this.toggleItem(item); + setTimeout(() => this.autocompleteTrigger?.openPanel()); + } else { + this.value.set(item); + } + + this.searchText.set(''); + } + + removeItem(item: T): void { + if (!this.multiple()) { + if (this.isSelected(item)) { + this.value.set(undefined); + } + return; + } + + this.value.set( + this.selectedItems().filter( + selected => !this.compare(selected, item), + ), + ); + } + + clearValue(): void { + this.value.set(this.multiple() ? [] : undefined); + this.searchText.set(''); + } + + isSelected(item: T): boolean { + return this.selectedItems().some(selected => + this.compare(selected, item), + ); + } + + displayValue(item: T): string { + if (item === undefined || item === null) { + return ''; + } + + if ( + typeof item === 'string' || + typeof item === 'number' || + typeof item === 'boolean' + ) { + return String(item); + } + + const record = this.toRecord(item); + const displayValue = DISPLAY_VALUE_FIELDS.map( + field => record?.[field], + ).find(value => value !== undefined && value !== null); + + return displayValue ? String(displayValue) : ''; + } + + itemColor(item: T): string | undefined { + const color = this.toRecord(item)?.['color']; + return typeof color === 'string' ? color : undefined; + } + + itemContrastColor(item: T): string | undefined { + const color = this.itemColor(item); + return color + ? this.colorizationService.generateContrastColor(color) + : undefined; + } + + protected trackItem = (_index: number, item: T): string => + this.itemKey(item); + + private toggleItem(item: T): void { + if (this.isSelected(item)) { + this.removeItem(item); + } else { + this.value.set([...this.selectedItems(), item]); + } + } + + private compare(a: T, b: T): boolean { + if (a === b) { + return true; + } + + const aRecord = this.toRecord(a); + const bRecord = this.toRecord(b); + + return IDENTITY_FIELDS.some(key => { + const aValue = aRecord?.[key]; + const bValue = bRecord?.[key]; + return aValue !== undefined && aValue === bValue; + }); + } + + private itemKey(item: T): string { + const record = this.toRecord(item); + const key = IDENTITY_FIELDS.map(field => record?.[field]).find( + value => value !== undefined && value !== null, + ); + + return String(key ?? this.displayValue(item)); + } + + private toRecord(item: T): Record | undefined { + if (item === undefined || item === null || typeof item !== 'object') { + return undefined; + } + + return item as Record; + } +} diff --git a/ui/projects/streampipes/shared-ui/src/lib/dialog/object-manage-dialog/object-manage-dialog.component.html b/ui/projects/streampipes/shared-ui/src/lib/dialog/object-manage-dialog/object-manage-dialog.component.html index e8ceedcf8b..ae3483a221 100644 --- a/ui/projects/streampipes/shared-ui/src/lib/dialog/object-manage-dialog/object-manage-dialog.component.html +++ b/ui/projects/streampipes/shared-ui/src/lib/dialog/object-manage-dialog/object-manage-dialog.component.html @@ -156,77 +156,21 @@

{{ headerTitle }}

[level]="3" [label]="'Authorized Users' | translate" > - - + - @for ( - user of grantedUserAuthorities; - track user - ) { - - {{ user.email }} - - - } - - - - - @for ( - user of filteredUsers$ - | async; - track user - ) { - - {{ user.email }} - - } - - + + @@ -237,80 +181,25 @@

{{ headerTitle }}

'Authorized Groups' | translate " > - - + - @for ( - group of grantedGroupAuthorities; - track group - ) { - - {{ group.groupName }} - - - } - - - - - @for ( - group of filteredGroups$ - | async; - track group - ) { - - {{ group.groupName }} - - } - - + + } diff --git a/ui/projects/streampipes/shared-ui/src/lib/dialog/object-manage-dialog/object-manage-dialog.component.ts b/ui/projects/streampipes/shared-ui/src/lib/dialog/object-manage-dialog/object-manage-dialog.component.ts index e87d0d1c9b..37d3e09722 100644 --- a/ui/projects/streampipes/shared-ui/src/lib/dialog/object-manage-dialog/object-manage-dialog.component.ts +++ b/ui/projects/streampipes/shared-ui/src/lib/dialog/object-manage-dialog/object-manage-dialog.component.ts @@ -16,11 +16,9 @@ * */ -import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { AfterViewInit, Component, - ElementRef, inject, Input, OnDestroy, @@ -54,27 +52,7 @@ import { UserService, UserInfo, } from '@streampipes/platform-services'; -import { - MatChipGrid, - MatChipInput, - MatChipInputEvent, - MatChipRemove, - MatChipRow, -} from '@angular/material/chips'; -import { - combineLatest, - firstValueFrom, - isObservable, - Observable, - shareReplay, - zip, -} from 'rxjs'; -import { - MatAutocomplete, - MatAutocompleteSelectedEvent, - MatAutocompleteTrigger, -} from '@angular/material/autocomplete'; -import { map, startWith } from 'rxjs/operators'; +import { firstValueFrom, isObservable, zip } from 'rxjs'; import { FlexDirective, LayoutAlignDirective, @@ -91,7 +69,6 @@ import { MatButton, MatIconButton } from '@angular/material/button'; import { MatTooltip } from '@angular/material/tooltip'; import { CdkCopyToClipboard } from '@angular/cdk/clipboard'; import { MatDivider } from '@angular/material/divider'; -import { AsyncPipe } from '@angular/common'; import { TranslatePipe } from '@ngx-translate/core'; import { ObjectManageDialogResource, @@ -99,6 +76,7 @@ import { ObjectManageDialogResult, ObjectManageDialogSaveMode, } from './model/object-manage-model.model'; +import { SearchSelectComponent } from '../../components/search-select/search-select.component'; @Component({ selector: 'sp-object-manage-dialog', @@ -117,23 +95,17 @@ import { MatSelect, MatOption, MatCheckbox, - MatChipGrid, - MatChipRow, - MatChipRemove, MatIcon, MatInput, - MatAutocompleteTrigger, - MatChipInput, - MatAutocomplete, LayoutGapDirective, MatIconButton, MatTooltip, CdkCopyToClipboard, MatDivider, MatButton, - AsyncPipe, TranslatePipe, AssetLinkConfigurationComponent, + SearchSelectComponent, ], }) export class ObjectManageDialogComponent< @@ -141,8 +113,6 @@ export class ObjectManageDialogComponent< > implements OnInit, AfterViewInit, OnDestroy { - separatorKeysCodes: number[] = [ENTER, COMMA]; - @Input() createMode: boolean = false; @@ -197,19 +167,12 @@ export class ObjectManageDialogComponent< allUsers: ShortUserInfo[]; allGroups: Group[]; - filteredUsers$: Observable; - filteredGroups$: Observable; - loading = true; permissionDenied = false; private assetRestoreInterval?: ReturnType; - @ViewChild('userInput') userInput: ElementRef; - @ViewChild('groupInput') groupInput: ElementRef; @ViewChild(AssetLinkConfigurationComponent) assetLinkConfigurationComponent?: AssetLinkConfigurationComponent; - userCtrl = new UntypedFormControl(); - groupCtrl = new UntypedFormControl(); private fb = inject(UntypedFormBuilder); private dialogRef = inject( @@ -363,34 +326,6 @@ export class ObjectManageDialogComponent< new UntypedFormControl(this.permission.readAnonymous), ); } - this.filteredUsers$ = combineLatest([ - this.userCtrl.valueChanges.pipe(startWith(null)), - this.parentForm - .get('owner')! - .valueChanges.pipe( - startWith(this.parentForm.get('owner')!.value), - ), - ]).pipe( - map(([username]) => { - const base = this.allUsers.filter( - u => !this.isOwnerOrAdded(u), - ); - return username ? this._filter(username) : base.slice(); - }), - shareReplay({ bufferSize: 1, refCount: true }), - ); - - this.filteredGroups$ = this.groupCtrl.valueChanges.pipe( - startWith(null), - map((groupName: string | null) => { - return groupName - ? this._filterGroup(groupName) - : this.allGroups - .filter(g => !this.isGroupAdded(g)) - .slice(); - }), - ); - this.permission.grantedAuthorities.forEach(authority => { if (authority.principalType === 'GROUP') { this.addGroupToSelection(authority); @@ -471,42 +406,22 @@ export class ObjectManageDialogComponent< this.dialogRef.close(result); } - removeUser(user: ShortUserInfo) { - const currentIndex = this.grantedUserAuthorities.findIndex( - u => u.principalId === user.principalId, - ); - this.grantedUserAuthorities.splice(currentIndex, 1); - this.userCtrl.setValue(null); - } - - removeGroup(group: Group) { - const currentIndex = this.grantedGroupAuthorities.findIndex( - u => u.groupId === group.groupId, - ); - this.grantedGroupAuthorities.splice(currentIndex, 1); - this.groupCtrl.setValue(null); - } - - addUser(event: MatChipInputEvent) { - event.chipInput.clear(); - this.userCtrl.setValue(null); + get availableUsers(): ShortUserInfo[] { + return this.allUsers?.filter(u => !this.isOwnerOrAdded(u)) ?? []; } - addGroup(event: MatChipInputEvent) { - event.chipInput.clear(); - this.groupCtrl.setValue(null); + get availableGroups(): Group[] { + return this.allGroups?.filter(g => !this.isGroupAdded(g)) ?? []; } - userSelected(event: MatAutocompleteSelectedEvent) { - this.grantedUserAuthorities.push(event.option.value); - this.userInput.nativeElement.value = ''; - this.userCtrl.setValue(null); + onGrantedUsersChange( + users: ShortUserInfo | ShortUserInfo[] | undefined, + ): void { + this.grantedUserAuthorities = Array.isArray(users) ? users : []; } - groupSelected(event: MatAutocompleteSelectedEvent) { - this.grantedGroupAuthorities.push(event.option.value); - this.groupInput.nativeElement.value = ''; - this.groupCtrl.setValue(null); + onGrantedGroupsChange(groups: Group | Group[] | undefined): void { + this.grantedGroupAuthorities = Array.isArray(groups) ? groups : []; } private addUserToSelection(authority: PermissionEntry) { diff --git a/ui/projects/streampipes/shared-ui/src/lib/dialog/object-permission-dialog/object-permission-dialog.component.html b/ui/projects/streampipes/shared-ui/src/lib/dialog/object-permission-dialog/object-permission-dialog.component.html index d6bfae790c..db71f21d62 100644 --- a/ui/projects/streampipes/shared-ui/src/lib/dialog/object-permission-dialog/object-permission-dialog.component.html +++ b/ui/projects/streampipes/shared-ui/src/lib/dialog/object-permission-dialog/object-permission-dialog.component.html @@ -74,66 +74,19 @@

{{ headerTitle }}

[level]="3" [label]="'Authorized Users' | translate" > - - + - @for ( - user of grantedUserAuthorities; - track user - ) { - - {{ user.email }} - - - } - - - - @for ( - user of filteredUsers$ | async; - track user - ) { - - {{ user.email }} - - } - - + + } @@ -146,67 +99,19 @@

{{ headerTitle }}

[level]="3" [label]="'Authorized Groups' | translate" > - - + - @for ( - group of grantedGroupAuthorities; - track group - ) { - - {{ group.groupName }} - - - } - - - - @for ( - group of filteredGroups$ | async; - track group - ) { - - {{ group.groupName }} - - } - - + + } diff --git a/ui/projects/streampipes/shared-ui/src/lib/dialog/object-permission-dialog/object-permission-dialog.component.ts b/ui/projects/streampipes/shared-ui/src/lib/dialog/object-permission-dialog/object-permission-dialog.component.ts index 984205efaa..119a9d83d9 100644 --- a/ui/projects/streampipes/shared-ui/src/lib/dialog/object-permission-dialog/object-permission-dialog.component.ts +++ b/ui/projects/streampipes/shared-ui/src/lib/dialog/object-permission-dialog/object-permission-dialog.component.ts @@ -16,15 +16,7 @@ * */ -import { COMMA, ENTER } from '@angular/cdk/keycodes'; -import { - Component, - ElementRef, - inject, - Input, - OnInit, - ViewChild, -} from '@angular/core'; +import { Component, inject, Input, OnInit } from '@angular/core'; import { DialogRef } from '../base-dialog/dialog-ref'; import { FormsModule, @@ -44,20 +36,7 @@ import { UserGroupService, UserService, } from '@streampipes/platform-services'; -import { - MatChipGrid, - MatChipInput, - MatChipInputEvent, - MatChipRemove, - MatChipRow, -} from '@angular/material/chips'; -import { combineLatest, Observable, shareReplay, zip } from 'rxjs'; -import { - MatAutocomplete, - MatAutocompleteSelectedEvent, - MatAutocompleteTrigger, -} from '@angular/material/autocomplete'; -import { map, startWith } from 'rxjs/operators'; +import { zip } from 'rxjs'; import { FlexDirective, LayoutAlignDirective, @@ -71,13 +50,12 @@ import { MatFormField } from '@angular/material/form-field'; import { MatOption, MatSelect } from '@angular/material/select'; import { MatCheckbox } from '@angular/material/checkbox'; import { MatIcon } from '@angular/material/icon'; -import { MatInput } from '@angular/material/input'; import { MatButton, MatIconButton } from '@angular/material/button'; import { MatTooltip } from '@angular/material/tooltip'; import { CdkCopyToClipboard } from '@angular/cdk/clipboard'; import { MatDivider } from '@angular/material/divider'; -import { AsyncPipe } from '@angular/common'; import { TranslatePipe } from '@ngx-translate/core'; +import { SearchSelectComponent } from '../../components/search-select/search-select.component'; @Component({ selector: 'sp-object-permission-dialog', @@ -96,27 +74,18 @@ import { TranslatePipe } from '@ngx-translate/core'; MatSelect, MatOption, MatCheckbox, - MatChipGrid, - MatChipRow, - MatChipRemove, MatIcon, - MatInput, - MatAutocompleteTrigger, - MatChipInput, - MatAutocomplete, LayoutGapDirective, MatIconButton, MatTooltip, CdkCopyToClipboard, MatDivider, MatButton, - AsyncPipe, TranslatePipe, + SearchSelectComponent, ], }) export class ObjectPermissionDialogComponent implements OnInit { - separatorKeysCodes: number[] = [ENTER, COMMA]; - @Input() objectInstanceId: string; @@ -140,17 +109,9 @@ export class ObjectPermissionDialogComponent implements OnInit { allUsers: ShortUserInfo[]; allGroups: Group[]; - filteredUsers$: Observable; - filteredGroups$: Observable; - loading = true; permissionDenied = false; - @ViewChild('userInput') userInput: ElementRef; - @ViewChild('groupInput') groupInput: ElementRef; - userCtrl = new UntypedFormControl(); - groupCtrl = new UntypedFormControl(); - private fb = inject(UntypedFormBuilder); private dialogRef = inject(DialogRef); private permissionsService = inject(PermissionsService); @@ -208,34 +169,6 @@ export class ObjectPermissionDialogComponent implements OnInit { new UntypedFormControl(this.permission.readAnonymous), ); } - this.filteredUsers$ = combineLatest([ - this.userCtrl.valueChanges.pipe(startWith(null)), - this.parentForm - .get('owner')! - .valueChanges.pipe( - startWith(this.parentForm.get('owner')!.value), - ), - ]).pipe( - map(([username]) => { - const base = this.allUsers.filter( - u => !this.isOwnerOrAdded(u), - ); - return username ? this._filter(username) : base.slice(); - }), - shareReplay({ bufferSize: 1, refCount: true }), - ); - - this.filteredGroups$ = this.groupCtrl.valueChanges.pipe( - startWith(null), - map((groupName: string | null) => { - return groupName - ? this._filterGroup(groupName) - : this.allGroups - .filter(g => !this.isGroupAdded(g)) - .slice(); - }), - ); - this.permission.grantedAuthorities.forEach(authority => { if (authority.principalType === 'GROUP') { this.addGroupToSelection(authority); @@ -285,42 +218,22 @@ export class ObjectPermissionDialogComponent implements OnInit { this.dialogRef.close(refresh); } - removeUser(user: ShortUserInfo) { - const currentIndex = this.grantedUserAuthorities.findIndex( - u => u.principalId === user.principalId, - ); - this.grantedUserAuthorities.splice(currentIndex, 1); - this.userCtrl.setValue(null); - } - - removeGroup(group: Group) { - const currentIndex = this.grantedGroupAuthorities.findIndex( - u => u.groupId === group.groupId, - ); - this.grantedGroupAuthorities.splice(currentIndex, 1); - this.groupCtrl.setValue(null); - } - - addUser(event: MatChipInputEvent) { - event.chipInput.clear(); - this.userCtrl.setValue(null); + get availableUsers(): ShortUserInfo[] { + return this.allUsers?.filter(u => !this.isOwnerOrAdded(u)) ?? []; } - addGroup(event: MatChipInputEvent) { - event.chipInput.clear(); - this.groupCtrl.setValue(null); + get availableGroups(): Group[] { + return this.allGroups?.filter(g => !this.isGroupAdded(g)) ?? []; } - userSelected(event: MatAutocompleteSelectedEvent) { - this.grantedUserAuthorities.push(event.option.value); - this.userInput.nativeElement.value = ''; - this.userCtrl.setValue(null); + onGrantedUsersChange( + users: ShortUserInfo | ShortUserInfo[] | undefined, + ): void { + this.grantedUserAuthorities = Array.isArray(users) ? users : []; } - groupSelected(event: MatAutocompleteSelectedEvent) { - this.grantedGroupAuthorities.push(event.option.value); - this.groupInput.nativeElement.value = ''; - this.groupCtrl.setValue(null); + onGrantedGroupsChange(groups: Group | Group[] | undefined): void { + this.grantedGroupAuthorities = Array.isArray(groups) ? groups : []; } private addUserToSelection(authority: PermissionEntry) { @@ -335,32 +248,6 @@ export class ObjectPermissionDialogComponent implements OnInit { this.grantedGroupAuthorities.push(group); } - private _filter(value: any): ShortUserInfo[] { - const isUserAccount = value instanceof ShortUserInfo; - const filterValue = isUserAccount - ? value.email.toLowerCase() - : value.toLowerCase(); - return this.allUsers.filter(u => { - return ( - u.email.toLowerCase().startsWith(filterValue) && - !this.isOwnerOrAdded(u) - ); - }); - } - - private _filterGroup(value: any): Group[] { - const isGroup = value instanceof Group; - const filterValue = isGroup - ? value.groupName.toLowerCase() - : value.toLowerCase(); - return this.allGroups.filter(g => { - return ( - g.groupName.toLowerCase().startsWith(filterValue) && - !this.isGroupAdded(g) - ); - }); - } - private isOwnerOrAdded(user: ShortUserInfo): boolean { return ( this.parentForm.get('owner').getRawValue() === user.principalId || diff --git a/ui/projects/streampipes/shared-ui/src/public-api.ts b/ui/projects/streampipes/shared-ui/src/public-api.ts index e586495e2c..dfa1358113 100644 --- a/ui/projects/streampipes/shared-ui/src/public-api.ts +++ b/ui/projects/streampipes/shared-ui/src/public-api.ts @@ -46,6 +46,8 @@ export * from './lib/components/element-id/element-id.component'; export * from './lib/components/form-field/form-field.component'; export * from './lib/components/form-label/form-label.component'; export * from './lib/components/property-scope-badge/property-scope-badge.component'; +export * from './lib/components/search-select/search-select.component'; +export * from './lib/components/search-select/search-select-template.directive'; export * from './lib/components/progress-bar/progress-bar.component'; export * from './lib/components/split-section/split-section.component'; export * from './lib/components/split-button/split-button.component'; diff --git a/ui/src/app/assets/components/asset-details/edit-asset/asset-details-panel/asset-details-basics/asset-details-labels/asset-details-labels.component.html b/ui/src/app/assets/components/asset-details/edit-asset/asset-details-panel/asset-details-basics/asset-details-labels/asset-details-labels.component.html index a8b8334d86..b8c81222a0 100644 --- a/ui/src/app/assets/components/asset-details/edit-asset/asset-details-panel/asset-details-basics/asset-details-labels/asset-details-labels.component.html +++ b/ui/src/app/assets/components/asset-details/edit-asset/asset-details-panel/asset-details-basics/asset-details-labels/asset-details-labels.component.html @@ -44,78 +44,21 @@ refresh - - - @for (label of labels; track label) { - - {{ label.label }} - - - } - - - - @for ( - label of filteredLabels | async; - track label - ) { - {{ label.label }} - } - - + } diff --git a/ui/src/app/assets/components/asset-details/edit-asset/asset-details-panel/asset-details-basics/asset-details-labels/asset-details-labels.component.ts b/ui/src/app/assets/components/asset-details/edit-asset/asset-details-panel/asset-details-basics/asset-details-labels/asset-details-labels.component.ts index 5e4c9ab779..76f68c9ed8 100644 --- a/ui/src/app/assets/components/asset-details/edit-asset/asset-details-panel/asset-details-basics/asset-details-labels/asset-details-labels.component.ts +++ b/ui/src/app/assets/components/asset-details/edit-asset/asset-details-panel/asset-details-basics/asset-details-labels/asset-details-labels.component.ts @@ -18,12 +18,10 @@ import { Component, - ElementRef, Input, OnChanges, OnInit, SimpleChanges, - ViewChild, inject, } from '@angular/core'; import { @@ -31,20 +29,10 @@ import { SpAsset, SpLabel, } from '@streampipes/platform-services'; -import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips'; -import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { COMMA, ENTER } from '@angular/cdk/keycodes'; -import { Observable } from 'rxjs'; -import { - MatAutocomplete, - MatAutocompleteSelectedEvent, - MatAutocompleteTrigger, -} from '@angular/material/autocomplete'; -import { map, startWith } from 'rxjs/operators'; import { FormFieldComponent, - SpColorizationService, SpLabelComponent, + SearchSelectComponent, } from '@streampipes/shared-ui'; import { FlexDirective, @@ -55,10 +43,6 @@ import { import { MatButton, MatIconButton } from '@angular/material/button'; import { RouterLink } from '@angular/router'; import { MatIcon } from '@angular/material/icon'; -import { MatFormField } from '@angular/material/form-field'; -import { AsyncPipe, NgStyle } from '@angular/common'; -import { StyleDirective } from '@ngbracket/ngx-layout/extended'; -import { MatOption } from '@angular/material/select'; import { TranslatePipe } from '@ngx-translate/core'; @Component({ @@ -74,23 +58,13 @@ import { TranslatePipe } from '@ngx-translate/core'; RouterLink, MatIconButton, MatIcon, - MatFormField, - MatChipsModule, - NgStyle, - StyleDirective, - FormsModule, - MatAutocompleteTrigger, - ReactiveFormsModule, - MatAutocomplete, - MatOption, + SearchSelectComponent, SpLabelComponent, - AsyncPipe, TranslatePipe, ], }) export class AssetDetailsLabelsComponent implements OnInit, OnChanges { private labelsService = inject(LabelsService); - private colorizationService = inject(SpColorizationService); @Input() asset: SpAsset; @@ -99,16 +73,9 @@ export class AssetDetailsLabelsComponent implements OnInit, OnChanges { editMode: boolean; labels: SpLabel[] = []; - labelTextColors: Record = {}; - - separatorKeysCodes: number[] = [ENTER, COMMA]; - labelCtrl = new FormControl(''); - filteredLabels: Observable; allLabels: SpLabel[] = []; labelsAvailable = false; - @ViewChild('labelInput') labelInput: ElementRef; - ngOnInit(): void { this.loadLabels(); } @@ -118,22 +85,9 @@ export class AssetDetailsLabelsComponent implements OnInit, OnChanges { this.allLabels = labels.sort((a, b) => a.label.localeCompare(b.label), ); - labels.forEach( - label => - (this.labelTextColors[label._id] = - this.colorizationService.generateContrastColor( - label.color, - )), - ); this.refreshCurrentLabels(); this.labelsAvailable = true; - this.updateFilteredLabels(); }); - - this.filteredLabels = this.labelCtrl.valueChanges.pipe( - startWith(''), - map(value => this._filter(value as string)), - ); } refreshCurrentLabels(): void { @@ -150,67 +104,11 @@ export class AssetDetailsLabelsComponent implements OnInit, OnChanges { ngOnChanges(changes: SimpleChanges) { if (changes['asset'] && this.labelsAvailable) { this.refreshCurrentLabels(); - this.updateFilteredLabels(); - } - } - - getAvailableLabels(): SpLabel[] { - return this.allLabels.filter( - label => - !this.labels.some( - selectedLabel => selectedLabel._id === label._id, - ), - ); - } - - add(event: MatChipInputEvent): void { - const value = (event.value || '').trim(); - if (value) { - this.addLabelToSelection(value); - } - event.chipInput?.clear(); - this.labelCtrl.setValue(null); - } - - findLabel(value: string): SpLabel { - return this.allLabels.find(l => l._id === value); - } - - remove(label: SpLabel): void { - const index = this.asset.labelIds.indexOf(label._id); - const labelsIndex = this.labels.findIndex(l => l._id === label._id); - if (index >= 0) { - this.labels.splice(labelsIndex, 1); - this.asset.labelIds.splice(index, 1); } - this.updateFilteredLabels(); - } - - selected(event: MatAutocompleteSelectedEvent): void { - this.addLabelToSelection(event.option.value); - this.labelInput.nativeElement.value = ''; - this.labelCtrl.setValue(null); - } - - addLabelToSelection(textLabel: string): void { - const label = this.findLabel(textLabel); - if (label && !this.labels.some(l => l._id === label._id)) { - this.labels.push(label); - this.asset.labelIds.push(label._id); - } - } - - private _filter(value: string): SpLabel[] { - const filterValue = value.toLowerCase(); - return this.getAvailableLabels().filter(label => - label.label.toLowerCase().includes(filterValue), - ); } - private updateFilteredLabels(): void { - this.filteredLabels = this.labelCtrl.valueChanges.pipe( - startWith(''), - map(value => this._filter(typeof value === 'string' ? value : '')), - ); + onLabelsChange(labels: SpLabel | SpLabel[] | undefined): void { + this.labels = Array.isArray(labels) ? labels : []; + this.asset.labelIds = this.labels.map(label => label._id); } } diff --git a/ui/src/app/chart-shared/components/chart-container/chart-container.component.ts b/ui/src/app/chart-shared/components/chart-container/chart-container.component.ts index 5206d4f22a..f0c5677634 100644 --- a/ui/src/app/chart-shared/components/chart-container/chart-container.component.ts +++ b/ui/src/app/chart-shared/components/chart-container/chart-container.component.ts @@ -274,7 +274,10 @@ export class ChartContainerComponent if ( typeChange.widgetId === this.configuredWidget.elementId ) { - this.chooseWidget(typeChange.newWidgetTypeId); + this.chooseWidget( + typeChange.newWidgetTypeId, + typeChange.deferInitialDataLoad, + ); } }, ); @@ -322,11 +325,14 @@ export class ChartContainerComponent this.interval$?.unsubscribe(); } - chooseWidget(widgetTypeId: string) { + chooseWidget(widgetTypeId: string, deferInitialDataLoad = false) { if (widgetTypeId != undefined && !this.showRequiresAttentionMessage) { const widgetToDisplay = this.chartRegistryService.getChartTemplate(widgetTypeId); - this.loadComponent(widgetToDisplay.widgetComponent); + this.loadComponent( + widgetToDisplay.widgetComponent, + deferInitialDataLoad, + ); } } @@ -338,7 +344,7 @@ export class ChartContainerComponent ); } - loadComponent(widgetToDisplay) { + loadComponent(widgetToDisplay, deferInitialDataLoad = false) { const container = this.el.nativeElement.querySelector( '.widget-content', ) as HTMLDivElement; @@ -373,6 +379,7 @@ export class ChartContainerComponent this.componentRef.instance.dataExplorerWidget = this.configuredWidget; this.componentRef.instance.previewMode = this.previewMode; this.componentRef.instance.gridMode = this.gridMode; + this.componentRef.instance.deferInitialDataLoad = deferInitialDataLoad; this.componentRef.instance.widgetIndex = this.widgetIndex; this.componentRef.instance.observableGenerator = this.observableGenerator; diff --git a/ui/src/app/chart-shared/components/charts/base/base-data-explorer-widget.directive.ts b/ui/src/app/chart-shared/components/charts/base/base-data-explorer-widget.directive.ts index e5bd69023e..889f7e200e 100644 --- a/ui/src/app/chart-shared/components/charts/base/base-data-explorer-widget.directive.ts +++ b/ui/src/app/chart-shared/components/charts/base/base-data-explorer-widget.directive.ts @@ -86,6 +86,9 @@ export abstract class BaseDataExplorerWidgetDirective< @Input() previewMode = false; + @Input() + deferInitialDataLoad = false; + @Input() gridMode = true; @@ -213,7 +216,9 @@ export abstract class BaseDataExplorerWidgetDirective< }, ); - this.updateData(); + if (!this.deferInitialDataLoad) { + this.updateData(); + } } public cleanupSubscriptions(): void { diff --git a/ui/src/app/chart-shared/models/dataview-dashboard.model.ts b/ui/src/app/chart-shared/models/dataview-dashboard.model.ts index b749ad6d95..68b3ce7dc3 100644 --- a/ui/src/app/chart-shared/models/dataview-dashboard.model.ts +++ b/ui/src/app/chart-shared/models/dataview-dashboard.model.ts @@ -49,6 +49,7 @@ export interface BaseWidgetData { dataViewMode: boolean; previewMode: boolean; gridMode: boolean; + deferInitialDataLoad?: boolean; widgetIndex?: number; dashboardChartOverrides?: DashboardChartOverrides; @@ -131,6 +132,7 @@ export interface WidgetBaseAppearanceConfig { export interface ChartTypeChangeMessage { widgetId: string; newWidgetTypeId: string; + deferInitialDataLoad?: boolean; } export interface RefreshMessage { diff --git a/ui/src/app/chart/components/chart-view/designer-panel/data-settings/chart-data-settings.component.html b/ui/src/app/chart/components/chart-view/designer-panel/data-settings/chart-data-settings.component.html index bb64ea35d8..0f884ccaff 100644 --- a/ui/src/app/chart/components/chart-view/designer-panel/data-settings/chart-data-settings.component.html +++ b/ui/src/app/chart/components/chart-view/designer-panel/data-settings/chart-data-settings.component.html @@ -84,74 +84,20 @@ } @if (availableMeasurements.length !== 0) { - - search - - @if (hasActiveMeasurementSearch()) { - - } - - @for ( - measurement of filteredMeasurements; - track measurement.elementId - ) { - - {{ - measurement.measureName - }} - - } - @if ( - filteredMeasurements.length === 0 && - hasActiveMeasurementSearch() - ) { - - {{ - 'No datasets match your search.' - | translate - }} - - } - - + } @if (sourceConfig.measureName) { @@ -400,7 +346,7 @@ #fieldSelectionPanel [sourceConfig]="sourceConfig" (initialFieldSelectionEvent)=" - createDefaultWidget() + onInitialFieldSelection() " > ; step = 0; @@ -173,17 +163,24 @@ export class ChartDataSettingsComponent implements OnInit { this.loadPipelinesAndMeasurements(); } + ngOnDestroy(): void { + clearTimeout(this.dataRefreshTimeout); + } + loadPipelinesAndMeasurements() { this.datalakeRestService.getMeasurementSummary().subscribe(response => { this.availableMeasurements = response.resources.sort((a, b) => a.measureName.localeCompare(b.measureName), ); - this.applyMeasurementSearch(); + this.syncSelectedMeasurementSummary(); if (!this.sourceConfig) { const defaultConfigs = this.findDefaultConfig(); this.initializeSourceConfig(defaultConfigs.measureName); if (defaultConfigs.measureName !== undefined) { + this.selectedMeasurement = this.findMeasurementSummary( + defaultConfigs.measureName, + ); this.loadMeasurement( defaultConfigs.measureName, true, @@ -229,45 +226,35 @@ export class ChartDataSettingsComponent implements OnInit { updateMeasure(sourceConfig: SourceConfig, measureName: string) { sourceConfig.measureName = measureName; - this.measurementInputValue = measureName; + this.selectedMeasurement = this.findMeasurementSummary(measureName); this.loadMeasurement(measureName, true, true); } - onMeasurementSearchChange(value: string): void { - this.measurementInputValue = value; - this.applyMeasurementSearch(); - } - - clearMeasurementSearch(): void { - this.measurementInputValue = ''; - this.applyMeasurementSearch(); - } - - hasActiveMeasurementSearch(): boolean { - return this.measurementInputValue.trim().length > 0; - } - - onMeasurementSelected( + onMeasurementSelectionChange( sourceConfig: SourceConfig, - event: MatAutocompleteSelectedEvent, + selectedMeasurement: + | DatasetSummaryDto + | DatasetSummaryDto[] + | undefined, ): void { - this.updateMeasure(sourceConfig, event.option.value); - } + if (Array.isArray(selectedMeasurement)) { + return; + } - private applyMeasurementSearch(): void { - const query = this.measurementInputValue.trim().toLowerCase(); - if (!query) { - this.filteredMeasurements = this.availableMeasurements; + if (!selectedMeasurement) { + this.clearMeasure(sourceConfig); return; } - this.filteredMeasurements = this.availableMeasurements.filter( - measurement => - measurement.measureName.toLowerCase().includes(query) || - measurement.pipelines.some(pipeline => - pipeline.toLowerCase().includes(query), - ), - ); + this.updateMeasure(sourceConfig, selectedMeasurement.measureName); + } + + private clearMeasure(sourceConfig: SourceConfig): void { + sourceConfig.measureName = ''; + sourceConfig.measure = undefined; + sourceConfig.queryConfig.fields = []; + sourceConfig.queryConfig.groupBy = []; + this.selectedMeasurement = undefined; } private loadMeasurement( @@ -300,12 +287,15 @@ export class ChartDataSettingsComponent implements OnInit { this.dataLakeMeasureChange.emit(measure); sourceConfig.measureName = measure.measureName; sourceConfig.measure = measure; - this.measurementInputValue = measure.measureName; + this.selectedMeasurement = this.findMeasurementSummary( + measure.measureName, + ); if (!resetQueryConfig) { return; } + this.pendingMeasurementRefresh = refreshData; sourceConfig.queryConfig.fields = []; if (this.fieldSelectionPanel) { this.fieldSelectionPanel.applyDefaultFields(); @@ -316,8 +306,8 @@ export class ChartDataSettingsComponent implements OnInit { this.groupSelectionPanel.applyDefaultFields(); } - if (refreshData) { - this.triggerDataRefresh(); + if (refreshData && this.fieldSelectionPanel) { + this.scheduleDataRefresh(); } } @@ -325,12 +315,32 @@ export class ChartDataSettingsComponent implements OnInit { if (this.sourceConfig?.measure) { this.dataLakeMeasure = this.sourceConfig.measure; this.dataLakeMeasureChange.emit(this.sourceConfig.measure); - this.measurementInputValue = this.sourceConfig.measure.measureName; + this.selectedMeasurement = this.findMeasurementSummary( + this.sourceConfig.measure.measureName, + ); } else if (this.sourceConfig?.measureName) { - this.measurementInputValue = this.sourceConfig.measureName; + this.selectedMeasurement = this.findMeasurementSummary( + this.sourceConfig.measureName, + ); + } + } + + private syncSelectedMeasurementSummary(): void { + if (this.sourceConfig?.measureName) { + this.selectedMeasurement = this.findMeasurementSummary( + this.sourceConfig.measureName, + ); } } + private findMeasurementSummary( + measureName: string, + ): DatasetSummaryDto | undefined { + return this.availableMeasurements.find( + measurement => measurement.measureName === measureName, + ); + } + changeDataAggregation() { this.fieldSelectionPanel.applyDefaultFields(); this.triggerDataRefresh(); @@ -368,7 +378,14 @@ export class ChartDataSettingsComponent implements OnInit { }; } - createDefaultWidget(): void { + onInitialFieldSelection(): void { + const defaultWidgetCreated = this.createDefaultWidget(); + if (defaultWidgetCreated || this.pendingMeasurementRefresh) { + this.scheduleDataRefresh(); + } + } + + createDefaultWidget(): boolean { if (this.checkIfDefaultTableShouldBeShown()) { const fields = this.fieldProviderService.generateFieldLists( this.dataConfig.sourceConfigs, @@ -379,13 +396,18 @@ export class ChartDataSettingsComponent implements OnInit { this.widgetTypeService.notify({ widgetId: this.currentlyConfiguredWidget.elementId, newWidgetTypeId: this.currentlyConfiguredWidget.widgetType, + deferInitialDataLoad: true, }); this.createWidgetEmitter.emit({ a: this.dataLakeMeasure, b: this.currentlyConfiguredWidget, }); + + return true; } + + return false; } /** @@ -404,6 +426,12 @@ export class ChartDataSettingsComponent implements OnInit { }); } + private scheduleDataRefresh(): void { + this.pendingMeasurementRefresh = false; + clearTimeout(this.dataRefreshTimeout); + this.dataRefreshTimeout = setTimeout(() => this.triggerDataRefresh()); + } + toggleExpandFieldsDataSource() { this.expandFieldsDataSource = !this.expandFieldsDataSource; } diff --git a/ui/src/app/connect/components/adapter-configuration/adapter-settings/adapter-deployment-settings/adapter-deployment-settings.component.html b/ui/src/app/connect/components/adapter-configuration/adapter-settings/adapter-deployment-settings/adapter-deployment-settings.component.html index 85b7c1b9fe..b25614c70a 100644 --- a/ui/src/app/connect/components/adapter-configuration/adapter-settings/adapter-deployment-settings/adapter-deployment-settings.component.html +++ b/ui/src/app/connect/components/adapter-configuration/adapter-settings/adapter-deployment-settings/adapter-deployment-settings.component.html @@ -37,43 +37,14 @@ @if (deploymentMode === 'filter') {
- - - @for ( - serviceTag of deploymentConfiguration.desiredServiceTags; - track serviceTag - ) { - - {{ serviceTag.value }} - - - } - - - - @for ( - serviceTag of filteredServiceTags | async; - track serviceTag - ) { - - {{ serviceTag }} - - } - - + +
} diff --git a/ui/src/app/connect/components/adapter-configuration/adapter-settings/adapter-deployment-settings/adapter-deployment-settings.component.ts b/ui/src/app/connect/components/adapter-configuration/adapter-settings/adapter-deployment-settings/adapter-deployment-settings.component.ts index 852e20378c..147fe632bd 100644 --- a/ui/src/app/connect/components/adapter-configuration/adapter-settings/adapter-deployment-settings/adapter-deployment-settings.component.ts +++ b/ui/src/app/connect/components/adapter-configuration/adapter-settings/adapter-deployment-settings/adapter-deployment-settings.component.ts @@ -16,41 +16,24 @@ * */ -import { - Component, - ElementRef, - inject, - Input, - OnInit, - ViewChild, -} from '@angular/core'; +import { Component, inject, Input, OnInit } from '@angular/core'; import { ExtensionDeploymentConfiguration, ServiceTagService, SpServiceTag, } from '@streampipes/platform-services'; -import { Observable } from 'rxjs'; -import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { COMMA, ENTER } from '@angular/cdk/keycodes'; -import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips'; -import { map, startWith } from 'rxjs/operators'; -import { - MatAutocomplete, - MatAutocompleteSelectedEvent, - MatAutocompleteTrigger, -} from '@angular/material/autocomplete'; +import { FormsModule } from '@angular/forms'; import { MatRadioButton, MatRadioChange, MatRadioGroup, } from '@angular/material/radio'; import { FlexDirective, LayoutDirective } from '@ngbracket/ngx-layout/flex'; -import { MatFormField } from '@angular/material/form-field'; -import { MatIcon } from '@angular/material/icon'; -import { MatOption } from '@angular/material/select'; -import { AsyncPipe } from '@angular/common'; import { TranslatePipe } from '@ngx-translate/core'; -import { FormFieldComponent } from '@streampipes/shared-ui'; +import { + FormFieldComponent, + SearchSelectComponent, +} from '@streampipes/shared-ui'; @Component({ selector: 'sp-adapter-deployment-settings', @@ -61,16 +44,9 @@ import { FormFieldComponent } from '@streampipes/shared-ui'; MatRadioGroup, FormsModule, MatRadioButton, - MatFormField, - MatChipsModule, - MatIcon, - MatAutocompleteTrigger, - ReactiveFormsModule, - MatAutocomplete, - MatOption, - AsyncPipe, TranslatePipe, FormFieldComponent, + SearchSelectComponent, ], }) export class SpAdapterDeploymentSettingsComponent implements OnInit { @@ -80,81 +56,26 @@ export class SpAdapterDeploymentSettingsComponent implements OnInit { deploymentConfiguration: ExtensionDeploymentConfiguration; availableServiceTags: SpServiceTag[] = []; - availableServiceTagValues: string[] = []; deploymentMode = 'all'; - separatorKeysCodes: number[] = [ENTER, COMMA]; - serviceTagCtrl = new FormControl(''); - filteredServiceTags: Observable; - - @ViewChild('serviceTagInput') serviceTagInput: ElementRef; - - constructor() { - this.filteredServiceTags = this.serviceTagCtrl.valueChanges.pipe( - startWith(null), - map((serviceTagValue: string | null) => { - return serviceTagValue - ? this._filter(serviceTagValue) - : this.availableServiceTagValues.slice(); - }), - ); - } - ngOnInit(): void { if (this.deploymentConfiguration.desiredServiceTags.length > 0) { this.deploymentMode = 'filter'; } this.serviceTagService.getCustomServiceTags().subscribe(res => { this.availableServiceTags = res; - this.availableServiceTagValues = res.map(st => st.value); }); } - add(event: MatChipInputEvent): void { - const value = (event.value || '').trim(); - - if (value) { - this.deploymentConfiguration.desiredServiceTags.push( - this.findTag(value), - ); - } - - if (event.chipInput) { - event.chipInput.clear(); - } - - this.serviceTagCtrl.setValue(null); - } - - findTag(value: string): SpServiceTag { - return this.availableServiceTags.find(st => st.value === value); - } - - remove(serviceTag: string): void { - const index = this.deploymentConfiguration.desiredServiceTags.findIndex( - st => st.value === serviceTag, - ); - - if (index >= 0) { - this.deploymentConfiguration.desiredServiceTags.splice(index, 1); - } - } - - selected(event: MatAutocompleteSelectedEvent): void { - this.deploymentConfiguration.desiredServiceTags.push( - this.findTag(event.option.viewValue), - ); - this.serviceTagInput.nativeElement.value = ''; - this.serviceTagCtrl.setValue(null); - } - - private _filter(value: string): string[] { - const filterValue = value.toLowerCase(); - - return this.availableServiceTagValues.filter(st => - st.toLowerCase().includes(filterValue), - ); + onServiceTagsChange( + serviceTags: SpServiceTag | SpServiceTag[] | undefined, + ): void { + this.deploymentConfiguration.desiredServiceTags = Array.isArray( + serviceTags, + ) + ? serviceTags + : []; } handleSelectionChange(event: MatRadioChange): void {