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">
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removal of the 'shadow' CSS class from the dropdown may affect visual consistency if other dropdowns in the application use shadows. Additionally, the new 'background-color: var(--lpx-content-bg)' CSS variable may not be defined in all themes, which could result in the dropdown having a transparent or incorrect background. Verify that this CSS variable is defined across all supported themes.

Suggested change
<div class="abp-lookup-dropdown list-group position-absolute w-100">
<div class="abp-lookup-dropdown list-group position-absolute w-100 shadow" style="background-color: var(--lpx-content-bg, #fff);">

Copilot uses AI. Check for mistakes.
@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 }}
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The localization key has been changed from 'AbpUi::NoRecordsFound' to 'AbpUi::NoDataAvailableInDatatable'. This is a breaking change that will cause the no results message to display the raw localization key instead of the translated text if the new key hasn't been added to the localization resources. Ensure this new key exists in all supported language files before merging.

Suggested change
{{ 'AbpUi::NoDataAvailableInDatatable' | abpLocalization }}
{{ 'AbpUi::NoRecordsFound' | abpLocalization }}

Copilot uses AI. Check for mistakes.
</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;
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The z-index has been increased from 1050 to 1060. Verify that this new z-index value doesn't conflict with other UI elements (such as modals, tooltips, or navigation menus) that may have z-index values in this range. Bootstrap modals typically use z-index of 1050-1055, so this change to 1060 should ensure the dropdown appears above modals, but confirm this is the intended behavior.

Suggested change
z-index: 1060;
z-index: 1050;

Copilot uses AI. Check for mistakes.
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);
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The showDropdown signal is being initialized to true instead of false. This will cause the dropdown to be visible by default when the component loads, which is likely unintended behavior. Users would see an empty dropdown until they interact with the search input.

Suggested change
readonly showDropdown = signal(true);
readonly showDropdown = signal(false);

Copilot uses AI. Check for mistakes.
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