Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,45 +1,61 @@
<div class="abp-lookup-container position-relative">
@if (label()) {
<label class="form-label">{{ label() | abpLocalization }}</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()" />
<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>
<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">
@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 }" />
} @else {
{{ getDisplayValue(item) }}
<div class="abp-lookup-dropdown list-group position-absolute w-100">
@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 }" />
} @else {
{{ getDisplayValue(item) }}
}
</button>
}
} @else if (displayValue()) {
@if (noResultsTemplate()) {
<ng-container *ngTemplateOutlet="noResultsTemplate()!" />
} @else {
<div class="list-group-item text-muted">
{{ 'AbpUi::NoDataAvailableInDatatable' | abpLocalization }}
</div>
}
}
</button>
}
} @else if (displayValue()) {
@if (noResultsTemplate()) {
<ng-container *ngTemplateOutlet="noResultsTemplate()!" />
} @else {
<div class="list-group-item text-muted">
{{ 'AbpUi::NoRecordsFound' | abpLocalization }}
</div>
}
}
</div>
}
</div>
</div>
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
.abp-lookup-dropdown {
z-index: 1050;
max-height: 200px;
overflow-y: auto;
z-index: 1060;
max-height: 200px;
overflow-y: auto;
top: 100%;
margin-top: 0.25rem;
background-color: var(--lpx-content-bg);
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import {
Component,
input,
output,
model,
signal,
OnInit,
ChangeDetectionStrategy,
TemplateRef,
contentChild,
DestroyRef,
inject,
Component,
input,
output,
model,
signal,
OnInit,
ChangeDetectionStrategy,
TemplateRef,
contentChild,
DestroyRef,
inject,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
Expand All @@ -18,119 +18,123 @@ import { LocalizationPipe } from '@abp/ng.core';
import { Subject, Observable, debounceTime, distinctUntilChanged, of, finalize } from 'rxjs';

export interface LookupItem {
key: string;
displayName: string;
[key: string]: unknown;
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',
styleUrl: './lookup-search.component.scss',
imports: [CommonModule, FormsModule, LocalizationPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'abp-lookup-search',
templateUrl: './lookup-search.component.html',
styleUrl: './lookup-search.component.scss',
imports: [CommonModule, FormsModule, LocalizationPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LookupSearchComponent<T extends LookupItem = LookupItem> implements OnInit {
private readonly destroyRef = inject(DestroyRef);

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);

readonly searchFn = input<LookupSearchFn<T>>(() => of([]));

readonly selectedValue = model<string>('');
readonly displayValue = model<string>('');

readonly itemSelected = output<T>();
readonly searchChanged = output<string>();

readonly itemTemplate = contentChild<TemplateRef<{ $implicit: T }>>('itemTemplate');
readonly noResultsTemplate = contentChild<TemplateRef<void>>('noResultsTemplate');

readonly searchResults = signal<T[]>([]);
readonly showDropdown = signal(false);
readonly isLoading = signal(false);

private readonly searchSubject = new Subject<string>();

ngOnInit() {
this.searchSubject.pipe(
debounceTime(this.debounceTime()),
distinctUntilChanged(),
takeUntilDestroyed(this.destroyRef)
).subscribe(filter => {
this.performSearch(filter);
});
}

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 readonly destroyRef = inject(DestroyRef);

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);

readonly searchFn = input<LookupSearchFn<T>>(() => of([]));

readonly selectedValue = model<string>('');
readonly displayValue = model<string>('');

readonly itemSelected = output<T>();
readonly searchChanged = output<string>();

readonly itemTemplate = contentChild<TemplateRef<{ $implicit: T }>>('itemTemplate');
readonly noResultsTemplate = contentChild<TemplateRef<void>>('noResultsTemplate');

readonly searchResults = signal<T[]>([]);
readonly showDropdown = signal(true);
readonly isLoading = signal(false);

private readonly searchSubject = new Subject<string>();

ngOnInit() {
this.searchSubject
.pipe(
debounceTime(this.debounceTime()),
distinctUntilChanged(),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(filter => {
this.performSearch(filter);
});
}

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([]);
}
}

private performSearch(filter: string) {
this.isLoading.set(true);

this.searchFn()(filter).pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => this.isLoading.set(false))
).subscribe({
next: results => {
this.searchResults.set(results);
},
error: () => {
this.searchResults.set([]);
}
});
onSearchFocus() {
this.showDropdown.set(true);
const currentFilter = this.displayValue() || '';
if (currentFilter.length >= this.minSearchLength()) {
this.performSearch(currentFilter);
}
}

getDisplayValue(item: T): string {
return String(item[this.displayKey()] ?? item[this.valueKey()] ?? '');
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(
takeUntilDestroyed(this.destroyRef),
finalize(() => this.isLoading.set(false)),
)
.subscribe({
next: results => {
this.searchResults.set(results);
},
error: () => {
this.searchResults.set([]);
},
});
}

getDisplayValue(item: T): string {
return String(item[this.displayKey()] ?? item[this.valueKey()] ?? '');
}
}
Loading
Loading