mirror of https://github.com/abpframework/abp.git
30 changed files with 821 additions and 158 deletions
@ -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"> |
|||
``` |
|||
@ -1,36 +1,13 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.ComponentModel.DataAnnotations; |
|||
using Volo.Abp.Reflection; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations.ObjectExtending |
|||
{ |
|||
[Serializable] |
|||
public class ExtensionPropertyAttributeDto |
|||
{ |
|||
public string Type { get; set; } |
|||
public string TypeSimple { get; set; } |
|||
public Dictionary<string, object> Configuration { get; set; } |
|||
|
|||
public static ExtensionPropertyAttributeDto Create(Attribute attribute) |
|||
{ |
|||
var attributeType = attribute.GetType(); |
|||
var dto = new ExtensionPropertyAttributeDto |
|||
{ |
|||
Type = TypeHelper.GetFullNameHandlingNullableAndGenerics(attributeType), |
|||
TypeSimple = TypeHelper.GetSimplifiedName(attributeType), |
|||
Configuration = new Dictionary<string, object>() |
|||
}; |
|||
|
|||
if (attribute is StringLengthAttribute stringLengthAttribute) |
|||
{ |
|||
dto.Configuration["MaximumLength"] = stringLengthAttribute.MaximumLength; |
|||
dto.Configuration["MinimumLength"] = stringLengthAttribute.MinimumLength; |
|||
} |
|||
|
|||
//TODO: Others!
|
|||
|
|||
return dto; |
|||
} |
|||
public Dictionary<string, object> Config { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
using System.Collections.Generic; |
|||
using Volo.Abp.AspNetCore.Mvc.UI.Bundling; |
|||
using Volo.Abp.AspNetCore.Mvc.UI.Packages.JQuery; |
|||
using Volo.Abp.Modularity; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.UI.Packages.JsTree |
|||
{ |
|||
[DependsOn(typeof(JQueryScriptContributor))] |
|||
public class JsTreeScriptContributor : BundleContributor |
|||
{ |
|||
public override void ConfigureBundle(BundleConfigurationContext context) |
|||
{ |
|||
context.Files.AddIfNotContains("/libs/jstree/jstree.min.js"); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
namespace Volo.Abp.AspNetCore.Mvc.UI.Packages.JsTree |
|||
{ |
|||
public class JsTreeOptions |
|||
{ |
|||
/// <summary>
|
|||
/// Path of the style file for the JsTree library.
|
|||
/// Setting to null ignores the style file.
|
|||
///
|
|||
/// Default value: "/libs/jstree/themes/default/style.min.css".
|
|||
/// </summary>
|
|||
public string StylePath { get; set; } = "/libs/jstree/themes/default/style.min.css"; |
|||
} |
|||
} |
|||
@ -0,0 +1,26 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Microsoft.Extensions.Options; |
|||
using Volo.Abp.AspNetCore.Mvc.UI.Bundling; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.UI.Packages.JsTree |
|||
{ |
|||
public class JsTreeStyleContributor : BundleContributor |
|||
{ |
|||
public override void ConfigureBundle(BundleConfigurationContext context) |
|||
{ |
|||
var options = context |
|||
.ServiceProvider |
|||
.GetRequiredService<IOptions<JsTreeOptions>>() |
|||
.Value; |
|||
|
|||
if (options.StylePath.IsNullOrEmpty()) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
context.Files.AddIfNotContains(options.StylePath); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,95 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.ComponentModel.DataAnnotations; |
|||
using System.Reflection; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.Reflection; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations.ObjectExtending |
|||
{ |
|||
public class ExtensionPropertyAttributeDtoFactory : IExtensionPropertyAttributeDtoFactory, ITransientDependency |
|||
{ |
|||
public virtual ExtensionPropertyAttributeDto Create(Attribute attribute) |
|||
{ |
|||
return new ExtensionPropertyAttributeDto |
|||
{ |
|||
TypeSimple = GetSimplifiedName(attribute), |
|||
Config = CreateConfiguration(attribute) |
|||
}; |
|||
} |
|||
|
|||
protected virtual string GetSimplifiedName(Attribute attribute) |
|||
{ |
|||
return attribute.GetType().Name.ToCamelCase().RemovePostFix("Attribute"); |
|||
} |
|||
|
|||
protected virtual Dictionary<string, object> CreateConfiguration(Attribute attribute) |
|||
{ |
|||
var configuration = new Dictionary<string, object>(); |
|||
|
|||
AddPropertiesToConfiguration(attribute, configuration); |
|||
|
|||
return configuration; |
|||
} |
|||
|
|||
protected virtual void AddPropertiesToConfiguration(Attribute attribute, Dictionary<string, object> configuration) |
|||
{ |
|||
var properties = attribute |
|||
.GetType() |
|||
.GetProperties(BindingFlags.Instance | BindingFlags.Public); |
|||
|
|||
foreach (var property in properties) |
|||
{ |
|||
if (IgnoreProperty(attribute, property)) |
|||
{ |
|||
continue; |
|||
} |
|||
|
|||
var value = GetPropertyValue(attribute, property); |
|||
if (value == null) |
|||
{ |
|||
continue; |
|||
} |
|||
|
|||
configuration[property.Name.ToCamelCase()] = value; |
|||
} |
|||
} |
|||
|
|||
protected virtual bool IgnoreProperty(Attribute attribute, PropertyInfo property) |
|||
{ |
|||
if (property.DeclaringType == null || |
|||
property.DeclaringType.IsIn(typeof(ValidationAttribute), typeof(Attribute), typeof(object))) |
|||
{ |
|||
return true; |
|||
} |
|||
|
|||
if (property.PropertyType == typeof(DisplayFormatAttribute)) |
|||
{ |
|||
return true; |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
protected virtual object GetPropertyValue(Attribute attribute, PropertyInfo property) |
|||
{ |
|||
var value = property.GetValue(attribute); |
|||
if (value == null) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
if (property.PropertyType.IsEnum) |
|||
{ |
|||
return Enum.GetName(property.PropertyType, value); |
|||
} |
|||
|
|||
if (property.PropertyType == typeof(Type)) |
|||
{ |
|||
return TypeHelper.GetSimplifiedName((Type) value); |
|||
} |
|||
|
|||
return value; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
using System; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations.ObjectExtending |
|||
{ |
|||
public interface IExtensionPropertyAttributeDtoFactory |
|||
{ |
|||
ExtensionPropertyAttributeDto Create(Attribute attribute); |
|||
} |
|||
} |
|||
@ -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>>; |
|||
@ -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 +1,2 @@ |
|||
export * from './list.token'; |
|||
export * from './options.token'; |
|||
|
|||
@ -0,0 +1,3 @@ |
|||
import { InjectionToken } from '@angular/core'; |
|||
|
|||
export const LIST_QUERY_DEBOUNCE_TIME = new InjectionToken<number>('LIST_QUERY_DEBOUNCE_TIME'); |
|||
@ -0,0 +1,5 @@ |
|||
module.exports = { |
|||
mappings: { |
|||
"@node_modules/jstree/dist/**/*.*": "@libs/jstree/" |
|||
} |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
{ |
|||
"version": "2.7.0", |
|||
"name": "@abp/jstree", |
|||
"publishConfig": { |
|||
"access": "public" |
|||
}, |
|||
"dependencies": { |
|||
"@abp/jquery": "^2.7.0", |
|||
"jstree": "^3.3.9" |
|||
}, |
|||
"gitHead": "0ea3895f3b0b489e3ea81fc88f8f0896b22b61bd" |
|||
} |
|||
Loading…
Reference in new issue