From c240b9059430c95bae05865edc4f806bc7e9b612 Mon Sep 17 00:00:00 2001 From: Fahri Gedik Date: Wed, 24 Dec 2025 12:21:55 +0300 Subject: [PATCH] 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. --- .../components/lookup/ng-package.json | 6 + .../src/lib/lookup-search.component.html | 46 ++++++ .../lookup/src/lib/lookup-search.component.ts | 144 ++++++++++++++++++ .../components/lookup/src/public-api.ts | 1 + .../provider-key-search.component.html | 19 +-- .../provider-key-search.component.ts | 73 ++++----- npm/ng-packs/tsconfig.base.json | 135 ++++++++++++---- 7 files changed, 335 insertions(+), 89 deletions(-) create mode 100644 npm/ng-packs/packages/components/lookup/ng-package.json create mode 100644 npm/ng-packs/packages/components/lookup/src/lib/lookup-search.component.html create mode 100644 npm/ng-packs/packages/components/lookup/src/lib/lookup-search.component.ts create mode 100644 npm/ng-packs/packages/components/lookup/src/public-api.ts diff --git a/npm/ng-packs/packages/components/lookup/ng-package.json b/npm/ng-packs/packages/components/lookup/ng-package.json new file mode 100644 index 0000000000..665ad2add2 --- /dev/null +++ b/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" + } +} \ No newline at end of file diff --git a/npm/ng-packs/packages/components/lookup/src/lib/lookup-search.component.html b/npm/ng-packs/packages/components/lookup/src/lib/lookup-search.component.html new file mode 100644 index 0000000000..6dd240c996 --- /dev/null +++ b/npm/ng-packs/packages/components/lookup/src/lib/lookup-search.component.html @@ -0,0 +1,46 @@ +
+ @if (label()) { + + } + +
+ + @if (displayValue() && !disabled()) { + + } +
+ + @if (showDropdown() && !disabled()) { +
+ @if (isLoading()) { +
+ + {{ 'AbpUi::Loading' | abpLocalization }} +
+ } @else if (searchResults().length > 0) { + @for (item of searchResults(); track item.key) { + + } + } @else if (displayValue()) { + @if (noResultsTemplate()) { + + } @else { +
+ {{ 'AbpUi::NoRecordsFound' | abpLocalization }} +
+ } + } +
+ } +
\ No newline at end of file diff --git a/npm/ng-packs/packages/components/lookup/src/lib/lookup-search.component.ts b/npm/ng-packs/packages/components/lookup/src/lib/lookup-search.component.ts new file mode 100644 index 0000000000..17f30f83a8 --- /dev/null +++ b/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 = (filter: string) => Observable; + +@Component({ + selector: 'abp-lookup-search', + templateUrl: './lookup-search.component.html', + imports: [CommonModule, FormsModule, LocalizationPipe], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LookupSearchComponent implements OnInit, OnDestroy { + // Inputs + readonly label = input(); + readonly placeholder = input(''); + readonly debounceTime = input(300); + readonly minSearchLength = input(0); + readonly displayKey = input('displayName' as keyof T); + readonly valueKey = input('key' as keyof T); + readonly disabled = input(false); + + // Search function - should be provided by parent + readonly searchFn = input>(() => of([])); + + // Two-way binding for selected value + readonly selectedValue = model(''); + readonly displayValue = model(''); + + // Outputs + readonly itemSelected = output(); + readonly searchChanged = output(); + + // Custom templates + readonly itemTemplate = contentChild>('itemTemplate'); + readonly noResultsTemplate = contentChild>('noResultsTemplate'); + + // Internal state + readonly searchResults = signal([]); + readonly showDropdown = signal(false); + readonly isLoading = signal(false); + + private readonly searchSubject = new Subject(); + private readonly destroy$ = new Subject(); + + 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()] ?? ''); + } +} diff --git a/npm/ng-packs/packages/components/lookup/src/public-api.ts b/npm/ng-packs/packages/components/lookup/src/public-api.ts new file mode 100644 index 0000000000..b1232ef08f --- /dev/null +++ b/npm/ng-packs/packages/components/lookup/src/public-api.ts @@ -0,0 +1 @@ +export * from './lib/lookup-search.component'; diff --git a/npm/ng-packs/packages/permission-management/src/lib/components/resource-permission-management/provider-key-search/provider-key-search.component.html b/npm/ng-packs/packages/permission-management/src/lib/components/resource-permission-management/provider-key-search/provider-key-search.component.html index 55e7000f27..41740a1f64 100644 --- a/npm/ng-packs/packages/permission-management/src/lib/components/resource-permission-management/provider-key-search/provider-key-search.component.html +++ b/npm/ng-packs/packages/permission-management/src/lib/components/resource-permission-management/provider-key-search/provider-key-search.component.html @@ -1,15 +1,4 @@ -
- - - @if (state.searchResults().length > 0 && state.showDropdown()) { -
- @for (result of state.searchResults(); track result.providerKey) { - - } -
- } -
\ No newline at end of file + \ No newline at end of file diff --git a/npm/ng-packs/packages/permission-management/src/lib/components/resource-permission-management/provider-key-search/provider-key-search.component.ts b/npm/ng-packs/packages/permission-management/src/lib/components/resource-permission-management/provider-key-search/provider-key-search.component.ts index 57bbf8600a..d8f626db54 100644 --- a/npm/ng-packs/packages/permission-management/src/lib/components/resource-permission-management/provider-key-search/provider-key-search.component.ts +++ b/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(); - readonly keySelected = output(); - - private readonly searchSubject = new Subject(); private readonly destroy$ = new Subject(); + searchFn: (filter: string) => Observable = () => 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 { 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$) + ); } } diff --git a/npm/ng-packs/tsconfig.base.json b/npm/ng-packs/tsconfig.base.json index f863f18520..07ab8e8ef4 100644 --- a/npm/ng-packs/tsconfig.base.json +++ b/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" + ] +} \ No newline at end of file