diff --git a/docs/en/UI/Angular/List-Service.md b/docs/en/UI/Angular/List-Service.md
new file mode 100644
index 0000000000..b3c8571866
--- /dev/null
+++ b/docs/en/UI/Angular/List-Service.md
@@ -0,0 +1,165 @@
+# Querying Lists Easily with ListService
+
+`ListService` is a utility service to provide an easy pagination, sorting, and search implementation.
+
+
+
+## Getting Started
+
+`ListService` is **not provided in root**. The reason is, this way, it will clear any subscriptions on component destroy. You may use the optional `LIST_QUERY_DEBOUNCE_TIME` token to adjust the debounce behavior.
+
+```js
+import { ListService } from '@abp/ng.core';
+import { BookDto } from '../models';
+import { BookService } from '../services';
+
+@Component({
+ /* class metadata here */
+ providers: [
+ // [Required]
+ ListService,
+
+ // [Optional]
+ // Provide this token if you want a different debounce time.
+ // Default is 300. Cannot be 0. Any value below 100 is not recommended.
+ { provide: LIST_QUERY_DEBOUNCE_TIME, useValue: 500 },
+ ],
+ template: `
+
+ `,
+})
+class BookComponent {
+ items: BookDto[] = [];
+ count = 0;
+
+ constructor(
+ public readonly list: ListService,
+ private bookService: BookService,
+ ) {}
+
+ ngOnInit() {
+ // A function that gets query and returns an observable
+ const bookStreamCreator = query => this.bookService.getList(query);
+
+ this.list.hookToQuery(bookStreamCreator).subscribe(
+ response => {
+ this.items = response.items;
+ this.count = response.count;
+ // If you use OnPush change detection strategy,
+ // call detectChanges method of ChangeDetectorRef here.
+ }
+ ); // Subscription is auto-cleared on destroy.
+ }
+}
+```
+
+> Noticed `list` is `public` and `readonly`? That is because we will use `ListService` properties directly in the component's template. That may be considered as an anti-pattern, but it is much quicker to implement. You can always use public component properties instead.
+
+Place `ListService` properties into the template like this:
+
+```html
+
+
+
+
+
+
+ {%{{{ '::Name' | abpLocalization }}}%}
+
+
+
+
+
+
+
+
{%{{{ data.name }}}%}
+
+
+```
+
+## Usage with Observables
+
+You may use observables in combination with [AsyncPipe](https://angular.io/guide/observables-in-angular#async-pipe) of Angular instead. Here are some possibilities:
+
+```ts
+ book$ = this.list.hookToQuery(query => this.bookService.getListByInput(query));
+```
+
+```html
+
+
+
+
+
+
+```
+
+...or...
+
+
+```ts
+ @Select(BookState.getBooks)
+ books$: Observable;
+
+ @Select(BookState.getBookCount)
+ bookCount$: Observable;
+
+ ngOnInit() {
+ this.list.hookToQuery((query) => this.store.dispatch(new GetBooks(query))).subscribe();
+ }
+```
+
+```html
+
+
+
+
+```
+
+## How to Refresh Table on Create/Update/Delete
+
+`ListService` exposes a `get` method to trigger a request with the current query. So, basically, whenever a create, update, or delete action resolves, you can call `this.list.get();` and it will call hooked stream creator again.
+
+```ts
+this.store.dispatch(new DeleteBook(id)).subscribe(this.list.get);
+```
+
+...or...
+
+```ts
+this.bookService.createByInput(form.value)
+ .subscribe(() => {
+ this.list.get();
+
+ // Other subscription logic here
+ })
+```
+
+## How to Implement Server-Side Search in a Table
+
+`ListService` exposes a `filter` property that will trigger a request with the current query and the given search string. All you need to do is to bind it to an input element with two-way binding.
+
+```html
+
+
+
+```
diff --git a/docs/en/UI/Angular/Track-By-Service.md b/docs/en/UI/Angular/Track-By-Service.md
index e6b560a2eb..447cc4505a 100644
--- a/docs/en/UI/Angular/Track-By-Service.md
+++ b/docs/en/UI/Angular/Track-By-Service.md
@@ -111,3 +111,9 @@ class DemoComponent {
trackByTenantAccountId = trackByDeep('tenant', 'account', 'id');
}
```
+
+
+
+## What's Next?
+
+- [ListService](./List-Service.md)
diff --git a/docs/en/docs-nav.json b/docs/en/docs-nav.json
index 5f6203054e..47789023c4 100644
--- a/docs/en/docs-nav.json
+++ b/docs/en/docs-nav.json
@@ -383,6 +383,10 @@
{
"text": "TrackByService",
"path": "UI/Angular/Track-By-Service.md"
+ },
+ {
+ "text": "ListService",
+ "path": "UI/Angular/List-Service.md"
}
]
},
diff --git a/npm/ng-packs/packages/core/src/lib/services/index.ts b/npm/ng-packs/packages/core/src/lib/services/index.ts
index f8b016bbd1..f01dc876de 100644
--- a/npm/ng-packs/packages/core/src/lib/services/index.ts
+++ b/npm/ng-packs/packages/core/src/lib/services/index.ts
@@ -4,6 +4,7 @@ export * from './config-state.service';
export * from './content-projection.service';
export * from './dom-insertion.service';
export * from './lazy-load.service';
+export * from './list.service';
export * from './localization.service';
export * from './profile-state.service';
export * from './profile.service';
diff --git a/npm/ng-packs/packages/core/src/lib/services/list.service.ts b/npm/ng-packs/packages/core/src/lib/services/list.service.ts
new file mode 100644
index 0000000000..944368ad88
--- /dev/null
+++ b/npm/ng-packs/packages/core/src/lib/services/list.service.ts
@@ -0,0 +1,101 @@
+import { Inject, Injectable, OnDestroy, Optional } from '@angular/core';
+import { BehaviorSubject, Observable, ReplaySubject } from 'rxjs';
+import { debounceTime, shareReplay, switchMap, tap } from 'rxjs/operators';
+import { ABP } from '../models/common';
+import { PagedResultDto } from '../models/dtos';
+import { LIST_QUERY_DEBOUNCE_TIME } from '../tokens/list.token';
+import { takeUntilDestroy } from '../utils/rxjs-utils';
+
+@Injectable()
+export class ListService implements OnDestroy {
+ private _filter = '';
+ set filter(value: string) {
+ this._filter = value;
+ this.get();
+ }
+ get filter(): string {
+ return this._filter;
+ }
+
+ private _maxResultCount = 10;
+ set maxResultCount(value: number) {
+ this._maxResultCount = value;
+ this.get();
+ }
+ get maxResultCount(): number {
+ return this._maxResultCount;
+ }
+
+ private _page = 1;
+ set page(value: number) {
+ this._page = value;
+ this.get();
+ }
+ get page(): number {
+ return this._page;
+ }
+
+ private _sortKey = '';
+ set sortKey(value: string) {
+ this._sortKey = value;
+ this.get();
+ }
+ get sortKey(): string {
+ return this._sortKey;
+ }
+
+ private _sortOrder = '';
+ set sortOrder(value: string) {
+ this._sortOrder = value;
+ this.get();
+ }
+ get sortOrder(): string {
+ return this._sortOrder;
+ }
+
+ private _query$ = new ReplaySubject(1);
+
+ get query$(): Observable {
+ return this._query$
+ .asObservable()
+ .pipe(debounceTime(this.delay || 300), shareReplay({ bufferSize: 1, refCount: true }));
+ }
+
+ private _isLoading$ = new BehaviorSubject(false);
+
+ get isLoading$(): Observable {
+ return this._isLoading$.asObservable();
+ }
+
+ get = () => {
+ this._query$.next({
+ filter: this._filter || undefined,
+ maxResultCount: this._maxResultCount,
+ skipCount: (this._page - 1) * this._maxResultCount,
+ sorting: this._sortOrder ? `${this._sortKey} ${this._sortOrder}` : undefined,
+ });
+ };
+
+ constructor(@Optional() @Inject(LIST_QUERY_DEBOUNCE_TIME) private delay: number) {
+ this.get();
+ }
+
+ hookToQuery(
+ streamCreatorCallback: QueryStreamCreatorCallback,
+ ): Observable> {
+ this._isLoading$.next(true);
+
+ return this.query$.pipe(
+ switchMap(streamCreatorCallback),
+ tap(() => this._isLoading$.next(false)),
+ shareReplay({ bufferSize: 1, refCount: true }),
+ takeUntilDestroy(this),
+ );
+ }
+
+ ngOnDestroy() {}
+}
+
+export type QueryStreamCreatorCallback = (
+ query: ABP.PageQueryParams,
+) => Observable>;
diff --git a/npm/ng-packs/packages/core/src/lib/tests/list.service.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/list.service.spec.ts
new file mode 100644
index 0000000000..c2118d7f3a
--- /dev/null
+++ b/npm/ng-packs/packages/core/src/lib/tests/list.service.spec.ts
@@ -0,0 +1,153 @@
+import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest';
+import { of } from 'rxjs';
+import { bufferCount, take } from 'rxjs/operators';
+import { ABP } from '../models';
+import { ListService, QueryStreamCreatorCallback } from '../services/list.service';
+import { LIST_QUERY_DEBOUNCE_TIME } from '../tokens';
+
+describe('ListService', () => {
+ let spectator: SpectatorService;
+ let service: ListService;
+
+ const createService = createServiceFactory({
+ service: ListService,
+ providers: [
+ {
+ provide: LIST_QUERY_DEBOUNCE_TIME,
+ useValue: 0,
+ },
+ ],
+ });
+
+ beforeEach(() => {
+ spectator = createService();
+ service = spectator.service;
+ });
+
+ describe('#filter', () => {
+ it('should initially be empty string', () => {
+ expect(service.filter).toBe('');
+ });
+
+ it('should be changed', () => {
+ service.filter = 'foo';
+
+ expect(service.filter).toBe('foo');
+ });
+ });
+
+ describe('#maxResultCount', () => {
+ it('should initially be 10', () => {
+ expect(service.maxResultCount).toBe(10);
+ });
+
+ it('should be changed', () => {
+ service.maxResultCount = 20;
+
+ expect(service.maxResultCount).toBe(20);
+ });
+ });
+
+ describe('#page', () => {
+ it('should initially be 1', () => {
+ expect(service.page).toBe(1);
+ });
+
+ it('should be changed', () => {
+ service.page = 9;
+
+ expect(service.page).toBe(9);
+ });
+ });
+
+ describe('#sortKey', () => {
+ it('should initially be empty string', () => {
+ expect(service.sortKey).toBe('');
+ });
+
+ it('should be changed', () => {
+ service.sortKey = 'foo';
+
+ expect(service.sortKey).toBe('foo');
+ });
+ });
+
+ describe('#sortOrder', () => {
+ it('should initially be empty string', () => {
+ expect(service.sortOrder).toBe('');
+ });
+
+ it('should be changed', () => {
+ service.sortOrder = 'foo';
+
+ expect(service.sortOrder).toBe('foo');
+ });
+ });
+
+ describe('#query$', () => {
+ it('should initially emit default query', done => {
+ service.query$.pipe(take(1)).subscribe(query => {
+ expect(query).toEqual({
+ filter: undefined,
+ maxResultCount: 10,
+ skipCount: 0,
+ sorting: undefined,
+ });
+
+ done();
+ });
+ });
+
+ it('should emit a query based on params set', done => {
+ service.filter = 'foo';
+ service.sortKey = 'bar';
+ service.sortOrder = 'baz';
+ service.maxResultCount = 20;
+ service.page = 9;
+
+ service.query$.pipe(take(1)).subscribe(query => {
+ expect(query).toEqual({
+ filter: 'foo',
+ sorting: 'bar baz',
+ maxResultCount: 20,
+ skipCount: 160,
+ });
+
+ done();
+ });
+ });
+ });
+
+ describe('#hookToQuery', () => {
+ it('should call given callback with the query', done => {
+ const callback: QueryStreamCreatorCallback = query =>
+ of({ items: [query], totalCount: 1 });
+
+ service.hookToQuery(callback).subscribe(({ items: [query] }) => {
+ expect(query).toEqual({
+ filter: undefined,
+ maxResultCount: 10,
+ skipCount: 0,
+ sorting: undefined,
+ });
+
+ done();
+ });
+ });
+
+ it('should emit isLoading as side effect', done => {
+ const callback: QueryStreamCreatorCallback = query =>
+ of({ items: [query], totalCount: 1 });
+
+ service.isLoading$.pipe(bufferCount(3)).subscribe(([idle, init, end]) => {
+ expect(idle).toBe(false);
+ expect(init).toBe(true);
+ expect(end).toBe(false);
+
+ done();
+ });
+
+ service.hookToQuery(callback).subscribe();
+ });
+ });
+});
diff --git a/npm/ng-packs/packages/core/src/lib/tokens/index.ts b/npm/ng-packs/packages/core/src/lib/tokens/index.ts
index 683bc4b3db..8d23d581a5 100644
--- a/npm/ng-packs/packages/core/src/lib/tokens/index.ts
+++ b/npm/ng-packs/packages/core/src/lib/tokens/index.ts
@@ -1 +1,2 @@
+export * from './list.token';
export * from './options.token';
diff --git a/npm/ng-packs/packages/core/src/lib/tokens/list.token.ts b/npm/ng-packs/packages/core/src/lib/tokens/list.token.ts
new file mode 100644
index 0000000000..b51ffb9fb3
--- /dev/null
+++ b/npm/ng-packs/packages/core/src/lib/tokens/list.token.ts
@@ -0,0 +1,3 @@
+import { InjectionToken } from '@angular/core';
+
+export const LIST_QUERY_DEBOUNCE_TIME = new InjectionToken('LIST_QUERY_DEBOUNCE_TIME');