Browse Source

Add reusable lookup-search component and refactor provider key search

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
Fahri Gedik 10 months ago
parent
commit
c240b90594
  1. 6
      npm/ng-packs/packages/components/lookup/ng-package.json
  2. 46
      npm/ng-packs/packages/components/lookup/src/lib/lookup-search.component.html
  3. 144
      npm/ng-packs/packages/components/lookup/src/lib/lookup-search.component.ts
  4. 1
      npm/ng-packs/packages/components/lookup/src/public-api.ts
  5. 19
      npm/ng-packs/packages/permission-management/src/lib/components/resource-permission-management/provider-key-search/provider-key-search.component.html
  6. 73
      npm/ng-packs/packages/permission-management/src/lib/components/resource-permission-management/provider-key-search/provider-key-search.component.ts
  7. 135
      npm/ng-packs/tsconfig.base.json

6
npm/ng-packs/packages/components/lookup/ng-package.json

@ -0,0 +1,6 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json",
"lib": {
"entryFile": "src/public-api.ts"
}
}

46
npm/ng-packs/packages/components/lookup/src/lib/lookup-search.component.html

@ -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>

144
npm/ng-packs/packages/components/lookup/src/lib/lookup-search.component.ts

@ -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()] ?? '');
}
}

1
npm/ng-packs/packages/components/lookup/src/public-api.ts

@ -0,0 +1 @@
export * from './lib/lookup-search.component';

19
npm/ng-packs/packages/permission-management/src/lib/components/resource-permission-management/provider-key-search/provider-key-search.component.html

@ -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)" />

73
npm/ng-packs/packages/permission-management/src/lib/components/resource-permission-management/provider-key-search/provider-key-search.component.ts

@ -1,14 +1,19 @@
import { Component, input, inject, output, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Component, input, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core';
import { LocalizationPipe } from '@abp/ng.core';
import { PermissionsService, SearchProviderKeyInfo } from '@abp/ng.permission-management/proxy';
import { Subject, debounceTime, distinctUntilChanged, takeUntil } from 'rxjs';
import { LookupSearchComponent, LookupItem } from '@abp/ng.components/lookup';
import { Observable, map, Subject, takeUntil } from 'rxjs';
import { ResourcePermissionStateService } from '../../../services/resource-permission-state.service';
interface ProviderKeyLookupItem extends LookupItem {
providerKey: string;
providerDisplayName?: string;
}
@Component({
selector: 'abp-provider-key-search',
templateUrl: './provider-key-search.component.html',
imports: [FormsModule, LocalizationPipe],
imports: [LocalizationPipe, LookupSearchComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProviderKeySearchComponent implements OnInit, OnDestroy {
@ -17,19 +22,12 @@ export class ProviderKeySearchComponent implements OnInit, OnDestroy {
readonly resourceName = input.required<string>();
readonly keySelected = output<SearchProviderKeyInfo>();
private readonly searchSubject = new Subject<string>();
private readonly destroy$ = new Subject<void>();
searchFn: (filter: string) => Observable<ProviderKeyLookupItem[]> = () => new Observable();
ngOnInit() {
this.searchSubject.pipe(
debounceTime(300),
distinctUntilChanged(),
takeUntil(this.destroy$)
).subscribe(filter => {
this.loadProviderKeys(filter);
});
this.searchFn = (filter: string) => this.loadProviderKeys(filter);
}
ngOnDestroy() {
@ -37,40 +35,33 @@ export class ProviderKeySearchComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
onSearchInput(filter: string) {
this.state.searchFilter.set(filter);
this.state.showDropdown.set(true);
this.searchSubject.next(filter);
}
onSearchFocus() {
this.state.showDropdown.set(true);
this.loadProviderKeys(this.state.searchFilter() || '');
onItemSelected(item: ProviderKeyLookupItem) {
// State is already updated via displayValue and selectedValue bindings
// This handler can be used for additional side effects if needed
}
onSearchBlur(event: FocusEvent) {
const relatedTarget = event.relatedTarget as HTMLElement;
if (!relatedTarget?.closest('.list-group')) {
this.state.showDropdown.set(false);
}
}
selectProviderKey(key: SearchProviderKeyInfo) {
this.state.selectProviderKey(key);
this.keySelected.emit(key);
}
private loadProviderKeys(filter: string) {
private loadProviderKeys(filter: string): Observable<ProviderKeyLookupItem[]> {
const providerName = this.state.selectedProviderName();
if (!providerName) return;
if (!providerName) {
return new Observable(subscriber => {
subscriber.next([]);
subscriber.complete();
});
}
this.service.searchResourceProviderKey(
return this.service.searchResourceProviderKey(
this.resourceName(),
providerName,
filter,
1
).subscribe(res => {
this.state.searchResults.set(res.keys || []);
});
).pipe(
map(res => (res.keys || []).map(k => ({
key: k.providerKey || '',
displayName: k.providerDisplayName || k.providerKey || '',
providerKey: k.providerKey || '',
providerDisplayName: k.providerDisplayName || undefined,
}))),
takeUntil(this.destroy$)
);
}
}

135
npm/ng-packs/tsconfig.base.json

@ -10,45 +10,114 @@
"importHelpers": true,
"target": "es2020",
"module": "esnext",
"lib": ["es2020", "dom"],
"lib": [
"es2020",
"dom"
],
"esModuleInterop": true,
"baseUrl": "./",
"allowSyntheticDefaultImports": true,
"paths": {
"@abp/ng.account": ["packages/account/src/public-api.ts"],
"@abp/ng.account.core": ["packages/account-core/src/public-api.ts"],
"@abp/ng.account.core/proxy": ["packages/account-core/proxy/src/public-api.ts"],
"@abp/ng.account/config": ["packages/account/config/src/public-api.ts"],
"@abp/ng.components": ["packages/components/src/public-api.ts"],
"@abp/ng.components/chart.js": ["packages/components/chart.js/src/public-api.ts"],
"@abp/ng.components/extensible": ["packages/components/extensible/src/public-api.ts"],
"@abp/ng.components/page": ["packages/components/page/src/public-api.ts"],
"@abp/ng.components/tree": ["packages/components/tree/src/public-api.ts"],
"@abp/ng.core": ["packages/core/src/public-api.ts"],
"@abp/ng.core/locale": ["packages/core/locale/src/public-api.ts"],
"@abp/ng.core/testing": ["packages/core/testing/src/public-api.ts"],
"@abp/ng.feature-management": ["packages/feature-management/src/public-api.ts"],
"@abp/ng.feature-management/proxy": ["packages/feature-management/proxy/src/public-api.ts"],
"@abp/ng.identity": ["packages/identity/src/public-api.ts"],
"@abp/ng.identity/config": ["packages/identity/config/src/public-api.ts"],
"@abp/ng.identity/proxy": ["packages/identity/proxy/src/public-api.ts"],
"@abp/ng.oauth": ["packages/oauth/src/public-api.ts"],
"@abp/ng.permission-management": ["packages/permission-management/src/public-api.ts"],
"@abp/ng.account": [
"packages/account/src/public-api.ts"
],
"@abp/ng.account.core": [
"packages/account-core/src/public-api.ts"
],
"@abp/ng.account.core/proxy": [
"packages/account-core/proxy/src/public-api.ts"
],
"@abp/ng.account/config": [
"packages/account/config/src/public-api.ts"
],
"@abp/ng.components": [
"packages/components/src/public-api.ts"
],
"@abp/ng.components/chart.js": [
"packages/components/chart.js/src/public-api.ts"
],
"@abp/ng.components/extensible": [
"packages/components/extensible/src/public-api.ts"
],
"@abp/ng.components/lookup": [
"packages/components/lookup/src/public-api.ts"
],
"@abp/ng.components/page": [
"packages/components/page/src/public-api.ts"
],
"@abp/ng.components/tree": [
"packages/components/tree/src/public-api.ts"
],
"@abp/ng.core": [
"packages/core/src/public-api.ts"
],
"@abp/ng.core/locale": [
"packages/core/locale/src/public-api.ts"
],
"@abp/ng.core/testing": [
"packages/core/testing/src/public-api.ts"
],
"@abp/ng.feature-management": [
"packages/feature-management/src/public-api.ts"
],
"@abp/ng.feature-management/proxy": [
"packages/feature-management/proxy/src/public-api.ts"
],
"@abp/ng.identity": [
"packages/identity/src/public-api.ts"
],
"@abp/ng.identity/config": [
"packages/identity/config/src/public-api.ts"
],
"@abp/ng.identity/proxy": [
"packages/identity/proxy/src/public-api.ts"
],
"@abp/ng.oauth": [
"packages/oauth/src/public-api.ts"
],
"@abp/ng.permission-management": [
"packages/permission-management/src/public-api.ts"
],
"@abp/ng.permission-management/proxy": [
"packages/permission-management/proxy/src/public-api.ts"
],
"@abp/ng.setting-management": ["packages/setting-management/src/public-api.ts"],
"@abp/ng.setting-management/config": ["packages/setting-management/config/src/public-api.ts"],
"@abp/ng.setting-management/proxy": ["packages/setting-management/proxy/src/public-api.ts"],
"@abp/ng.tenant-management": ["packages/tenant-management/src/public-api.ts"],
"@abp/ng.tenant-management/config": ["packages/tenant-management/config/src/public-api.ts"],
"@abp/ng.tenant-management/proxy": ["packages/tenant-management/proxy/src/public-api.ts"],
"@abp/ng.theme.basic": ["packages/theme-basic/src/public-api.ts"],
"@abp/ng.theme.basic/testing": ["packages/theme-basic/testing/src/public-api.ts"],
"@abp/ng.theme.shared": ["packages/theme-shared/src/public-api.ts"],
"@abp/ng.theme.shared/testing": ["packages/theme-shared/testing/src/public-api.ts"],
"@abp/nx.generators": ["packages/generators/src/index.ts"]
"@abp/ng.setting-management": [
"packages/setting-management/src/public-api.ts"
],
"@abp/ng.setting-management/config": [
"packages/setting-management/config/src/public-api.ts"
],
"@abp/ng.setting-management/proxy": [
"packages/setting-management/proxy/src/public-api.ts"
],
"@abp/ng.tenant-management": [
"packages/tenant-management/src/public-api.ts"
],
"@abp/ng.tenant-management/config": [
"packages/tenant-management/config/src/public-api.ts"
],
"@abp/ng.tenant-management/proxy": [
"packages/tenant-management/proxy/src/public-api.ts"
],
"@abp/ng.theme.basic": [
"packages/theme-basic/src/public-api.ts"
],
"@abp/ng.theme.basic/testing": [
"packages/theme-basic/testing/src/public-api.ts"
],
"@abp/ng.theme.shared": [
"packages/theme-shared/src/public-api.ts"
],
"@abp/ng.theme.shared/testing": [
"packages/theme-shared/testing/src/public-api.ts"
],
"@abp/nx.generators": [
"packages/generators/src/index.ts"
]
}
},
"exclude": ["node_modules", "tmp"]
}
"exclude": [
"node_modules",
"tmp"
]
}
Loading…
Cancel
Save