Browse Source

Merge pull request #3956 from abpframework/feat/3866

Added ListService for Easy Pagination, Sorting, and Search
pull/3961/head
Mehmet Erim 6 years ago
committed by GitHub
parent
commit
279d250fbd
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 165
      docs/en/UI/Angular/List-Service.md
  2. 6
      docs/en/UI/Angular/Track-By-Service.md
  3. 4
      docs/en/docs-nav.json
  4. 1
      npm/ng-packs/packages/core/src/lib/services/index.ts
  5. 101
      npm/ng-packs/packages/core/src/lib/services/list.service.ts
  6. 153
      npm/ng-packs/packages/core/src/lib/tests/list.service.spec.ts
  7. 1
      npm/ng-packs/packages/core/src/lib/tokens/index.ts
  8. 3
      npm/ng-packs/packages/core/src/lib/tokens/list.token.ts

165
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
<abp-table
[value]="book.items"
[(page)]="list.page"
[rows]="list.maxResultCount"
[totalRecords]="book.totalCount"
[headerTemplate]="tableHeader"
[bodyTemplate]="tableBody"
[abpLoading]="list.isLoading$ | async"
>
</abp-table>
<ng-template #tableHeader>
<tr>
<th (click)="nameSort.sort('name')">
{%{{{ '::Name' | abpLocalization }}}%}
<abp-sort-order-icon
#nameSort
sortKey="name"
[(selectedSortKey)]="list.sortKey"
[(order)]="list.sortOrder"
></abp-sort-order-icon>
</th>
</tr>
</ng-template>
<ng-template #tableBody let-data>
<tr>
<td>{%{{{ data.name }}}%}</td>
</tr>
</ng-template>
```
## 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
<!-- simplified representation of the template -->
<abp-table
[value]="(book$ | async)?.items || []"
[totalRecords]="(book$ | async)?.totalCount"
>
</abp-table>
<!-- DO NOT WORRY, ONLY ONE REQUEST WILL BE MADE -->
```
...or...
```ts
@Select(BookState.getBooks)
books$: Observable<BookDto[]>;
@Select(BookState.getBookCount)
bookCount$: Observable<number>;
ngOnInit() {
this.list.hookToQuery((query) => this.store.dispatch(new GetBooks(query))).subscribe();
}
```
```html
<!-- simplified representation of the template -->
<abp-table
[value]="books$ | async"
[totalRecords]="bookCount$ | async"
>
</abp-table>
```
## 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
<!-- simplified representation -->
<input type="text" name="search" [(ngModel)]="list.filter">
```

6
docs/en/UI/Angular/Track-By-Service.md

@ -111,3 +111,9 @@ class DemoComponent {
trackByTenantAccountId = trackByDeep<Item>('tenant', 'account', 'id');
}
```
## What's Next?
- [ListService](./List-Service.md)

4
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"
}
]
},

1
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';

101
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<ABP.PageQueryParams>(1);
get query$(): Observable<ABP.PageQueryParams> {
return this._query$
.asObservable()
.pipe(debounceTime(this.delay || 300), shareReplay({ bufferSize: 1, refCount: true }));
}
private _isLoading$ = new BehaviorSubject(false);
get isLoading$(): Observable<boolean> {
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<T extends any>(
streamCreatorCallback: QueryStreamCreatorCallback<T>,
): Observable<PagedResultDto<T>> {
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<T> = (
query: ABP.PageQueryParams,
) => Observable<PagedResultDto<T>>;

153
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<ListService>;
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<ABP.PageQueryParams> = 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<ABP.PageQueryParams> = 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();
});
});
});

1
npm/ng-packs/packages/core/src/lib/tokens/index.ts

@ -1 +1,2 @@
export * from './list.token';
export * from './options.token';

3
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<number>('LIST_QUERY_DEBOUNCE_TIME');
Loading…
Cancel
Save