mirror of https://github.com/abpframework/abp.git
Browse Source
Introduces a generic LookupSearchComponent for reusable lookup/search UI in Angular. Refactors the provider key search in permission management to use the new component, simplifying its logic and improving maintainability. Updates tsconfig paths to include the new lookup component package.pull/24459/head
7 changed files with 335 additions and 89 deletions
@ -0,0 +1,6 @@ |
|||
{ |
|||
"$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", |
|||
"lib": { |
|||
"entryFile": "src/public-api.ts" |
|||
} |
|||
} |
|||
@ -0,0 +1,46 @@ |
|||
<div class="abp-lookup-container position-relative"> |
|||
@if (label()) { |
|||
<label class="form-label">{{ label() | abpLocalization }}</label> |
|||
} |
|||
|
|||
<div class="input-group"> |
|||
<input type="text" class="form-control" [placeholder]="placeholder() | abpLocalization" |
|||
[ngModel]="displayValue()" (ngModelChange)="onSearchInput($event)" (focus)="onSearchFocus()" |
|||
(blur)="onSearchBlur($event)" [disabled]="disabled()" /> |
|||
@if (displayValue() && !disabled()) { |
|||
<button type="button" class="btn btn-outline-secondary" (mousedown)="clearSelection()" tabindex="-1"> |
|||
<i class="fa fa-times"></i> |
|||
</button> |
|||
} |
|||
</div> |
|||
|
|||
@if (showDropdown() && !disabled()) { |
|||
<div class="abp-lookup-dropdown list-group position-absolute w-100 shadow" |
|||
style="z-index: 1050; max-height: 200px; overflow-y: auto;"> |
|||
@if (isLoading()) { |
|||
<div class="list-group-item text-center py-3"> |
|||
<i class="fa fa-spinner fa-spin me-2"></i> |
|||
{{ 'AbpUi::Loading' | abpLocalization }} |
|||
</div> |
|||
} @else if (searchResults().length > 0) { |
|||
@for (item of searchResults(); track item.key) { |
|||
<button type="button" class="list-group-item list-group-item-action" (mousedown)="selectItem(item)"> |
|||
@if (itemTemplate()) { |
|||
<ng-container *ngTemplateOutlet="itemTemplate()!; context: { $implicit: item }"></ng-container> |
|||
} @else { |
|||
{{ getDisplayValue(item) }} |
|||
} |
|||
</button> |
|||
} |
|||
} @else if (displayValue()) { |
|||
@if (noResultsTemplate()) { |
|||
<ng-container *ngTemplateOutlet="noResultsTemplate()!"></ng-container> |
|||
} @else { |
|||
<div class="list-group-item text-muted"> |
|||
{{ 'AbpUi::NoRecordsFound' | abpLocalization }} |
|||
</div> |
|||
} |
|||
} |
|||
</div> |
|||
} |
|||
</div> |
|||
@ -0,0 +1,144 @@ |
|||
import { |
|||
Component, |
|||
input, |
|||
output, |
|||
model, |
|||
signal, |
|||
OnInit, |
|||
OnDestroy, |
|||
ChangeDetectionStrategy, |
|||
TemplateRef, |
|||
contentChild, |
|||
} from '@angular/core'; |
|||
import { CommonModule } from '@angular/common'; |
|||
import { FormsModule } from '@angular/forms'; |
|||
import { LocalizationPipe } from '@abp/ng.core'; |
|||
import { Subject, Observable, debounceTime, distinctUntilChanged, takeUntil, of } from 'rxjs'; |
|||
|
|||
export interface LookupItem { |
|||
key: string; |
|||
displayName: string; |
|||
[key: string]: unknown; |
|||
} |
|||
|
|||
export type LookupSearchFn<T = LookupItem> = (filter: string) => Observable<T[]>; |
|||
|
|||
@Component({ |
|||
selector: 'abp-lookup-search', |
|||
templateUrl: './lookup-search.component.html', |
|||
imports: [CommonModule, FormsModule, LocalizationPipe], |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
}) |
|||
export class LookupSearchComponent<T extends LookupItem = LookupItem> implements OnInit, OnDestroy { |
|||
// Inputs
|
|||
readonly label = input<string>(); |
|||
readonly placeholder = input<string>(''); |
|||
readonly debounceTime = input<number>(300); |
|||
readonly minSearchLength = input<number>(0); |
|||
readonly displayKey = input<keyof T>('displayName' as keyof T); |
|||
readonly valueKey = input<keyof T>('key' as keyof T); |
|||
readonly disabled = input<boolean>(false); |
|||
|
|||
// Search function - should be provided by parent
|
|||
readonly searchFn = input<LookupSearchFn<T>>(() => of([])); |
|||
|
|||
// Two-way binding for selected value
|
|||
readonly selectedValue = model<string>(''); |
|||
readonly displayValue = model<string>(''); |
|||
|
|||
// Outputs
|
|||
readonly itemSelected = output<T>(); |
|||
readonly searchChanged = output<string>(); |
|||
|
|||
// Custom templates
|
|||
readonly itemTemplate = contentChild<TemplateRef<{ $implicit: T }>>('itemTemplate'); |
|||
readonly noResultsTemplate = contentChild<TemplateRef<void>>('noResultsTemplate'); |
|||
|
|||
// Internal state
|
|||
readonly searchResults = signal<T[]>([]); |
|||
readonly showDropdown = signal(false); |
|||
readonly isLoading = signal(false); |
|||
|
|||
private readonly searchSubject = new Subject<string>(); |
|||
private readonly destroy$ = new Subject<void>(); |
|||
|
|||
ngOnInit() { |
|||
this.searchSubject.pipe( |
|||
debounceTime(this.debounceTime()), |
|||
distinctUntilChanged(), |
|||
takeUntil(this.destroy$) |
|||
).subscribe(filter => { |
|||
this.performSearch(filter); |
|||
}); |
|||
} |
|||
|
|||
ngOnDestroy() { |
|||
this.destroy$.next(); |
|||
this.destroy$.complete(); |
|||
} |
|||
|
|||
onSearchInput(filter: string) { |
|||
this.displayValue.set(filter); |
|||
this.showDropdown.set(true); |
|||
this.searchChanged.emit(filter); |
|||
|
|||
if (filter.length >= this.minSearchLength()) { |
|||
this.searchSubject.next(filter); |
|||
} else { |
|||
this.searchResults.set([]); |
|||
} |
|||
} |
|||
|
|||
onSearchFocus() { |
|||
this.showDropdown.set(true); |
|||
const currentFilter = this.displayValue() || ''; |
|||
if (currentFilter.length >= this.minSearchLength()) { |
|||
this.performSearch(currentFilter); |
|||
} |
|||
} |
|||
|
|||
onSearchBlur(event: FocusEvent) { |
|||
const relatedTarget = event.relatedTarget as HTMLElement; |
|||
if (!relatedTarget?.closest('.abp-lookup-dropdown')) { |
|||
this.showDropdown.set(false); |
|||
} |
|||
} |
|||
|
|||
selectItem(item: T) { |
|||
const displayKeyValue = String(item[this.displayKey()] ?? ''); |
|||
const valueKeyValue = String(item[this.valueKey()] ?? ''); |
|||
|
|||
this.displayValue.set(displayKeyValue); |
|||
this.selectedValue.set(valueKeyValue); |
|||
this.searchResults.set([]); |
|||
this.showDropdown.set(false); |
|||
this.itemSelected.emit(item); |
|||
} |
|||
|
|||
clearSelection() { |
|||
this.displayValue.set(''); |
|||
this.selectedValue.set(''); |
|||
this.searchResults.set([]); |
|||
} |
|||
|
|||
private performSearch(filter: string) { |
|||
this.isLoading.set(true); |
|||
|
|||
this.searchFn()(filter).pipe( |
|||
takeUntil(this.destroy$) |
|||
).subscribe({ |
|||
next: results => { |
|||
this.searchResults.set(results); |
|||
this.isLoading.set(false); |
|||
}, |
|||
error: () => { |
|||
this.searchResults.set([]); |
|||
this.isLoading.set(false); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
getDisplayValue(item: T): string { |
|||
return String(item[this.displayKey()] ?? item[this.valueKey()] ?? ''); |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export * from './lib/lookup-search.component'; |
|||
@ -1,15 +1,4 @@ |
|||
<div class="position-relative"> |
|||
<label class="form-label">{{ 'AbpPermissionManagement::SearchProviderKey' | abpLocalization }}</label> |
|||
<input type="text" class="form-control" |
|||
[placeholder]="'AbpPermissionManagement::SearchProviderKey' | abpLocalization" [ngModel]="state.searchFilter()" |
|||
(ngModelChange)="onSearchInput($event)" (focus)="onSearchFocus()" (blur)="onSearchBlur($event)" /> |
|||
@if (state.searchResults().length > 0 && state.showDropdown()) { |
|||
<div class="list-group position-absolute w-100 shadow" style="z-index: 1000; max-height: 200px; overflow-y: auto;"> |
|||
@for (result of state.searchResults(); track result.providerKey) { |
|||
<button type="button" class="list-group-item list-group-item-action" (mousedown)="selectProviderKey(result)"> |
|||
{{ result.providerDisplayName || result.providerKey }} |
|||
</button> |
|||
} |
|||
</div> |
|||
} |
|||
</div> |
|||
<abp-lookup-search label="AbpPermissionManagement::SearchProviderKey" |
|||
placeholder="AbpPermissionManagement::SearchProviderKey" [searchFn]="searchFn" [displayValue]="state.searchFilter()" |
|||
(displayValueChange)="state.searchFilter.set($event)" [selectedValue]="state.selectedProviderKey()" |
|||
(selectedValueChange)="state.selectedProviderKey.set($event)" (itemSelected)="onItemSelected($event)" /> |
|||
Loading…
Reference in new issue