mirror of https://github.com/abpframework/abp.git
31 changed files with 763 additions and 123 deletions
@ -0,0 +1,39 @@ |
|||
import { Component, OnInit } from '@angular/core'; |
|||
|
|||
@Component({ |
|||
selector: 'abp-loading', |
|||
template: ` |
|||
<div class="abp-loading"> |
|||
<i class="fa fa-spinner fa-pulse abp-spinner"></i> |
|||
</div> |
|||
`,
|
|||
styles: [ |
|||
` |
|||
.abp-loading { |
|||
background: rgba(0, 0, 0, 0.2); |
|||
position: absolute; |
|||
width: 100%; |
|||
height: 100%; |
|||
top: 0; |
|||
left: 0; |
|||
z-index: 1040; |
|||
} |
|||
|
|||
.abp-loading .abp-spinner { |
|||
position: absolute; |
|||
top: 50%; |
|||
left: 50%; |
|||
-moz-transform: translateX(-50%) translateY(-50%); |
|||
-o-transform: translateX(-50%) translateY(-50%); |
|||
-ms-transform: translateX(-50%) translateY(-50%); |
|||
-webkit-transform: translateX(-50%) translateY(-50%); |
|||
transform: translateX(-50%) translateY(-50%); |
|||
} |
|||
`,
|
|||
], |
|||
}) |
|||
export class LoadingComponent implements OnInit { |
|||
constructor() {} |
|||
|
|||
ngOnInit() {} |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
<div |
|||
class="ui-paginator-bottom ui-paginator ui-widget ui-widget-header ui-unselectable-text ui-helper-clearfix" |
|||
> |
|||
<a |
|||
class="ui-paginator-first ui-paginator-element ui-state-default ui-corner-all" |
|||
[class.ui-state-disabled]="value === 1" |
|||
tabindex="-1" |
|||
(click)="changePage(1)" |
|||
><span class="ui-paginator-icon pi pi-step-backward"></span></a |
|||
><a |
|||
class="ui-paginator-prev ui-paginator-element ui-state-default ui-corner-all" |
|||
[class.ui-state-disabled]="value === 1" |
|||
tabindex="-1" |
|||
(click)="changePage(value - 1)" |
|||
><span class="ui-paginator-icon pi pi-caret-left"></span></a |
|||
><span class="ui-paginator-pages" |
|||
><a |
|||
*ngFor="let page of pageArray; trackBy: trackByFn" |
|||
(click)="changePage(page)" |
|||
class="ui-paginator-page ui-paginator-element ui-state-default ui-corner-all" |
|||
[class.ui-state-active]="page === value" |
|||
tabindex="0" |
|||
>{{ page }}</a |
|||
></span |
|||
><a |
|||
class="ui-paginator-next ui-paginator-element ui-state-default ui-corner-all" |
|||
[class.ui-state-disabled]="value === totalPages" |
|||
tabindex="0" |
|||
(click)="changePage(value + 1)" |
|||
><span class="ui-paginator-icon pi pi-caret-right"></span></a |
|||
><a |
|||
class="ui-paginator-last ui-paginator-element ui-state-default ui-corner-all" |
|||
[class.ui-state-disabled]="value === totalPages" |
|||
tabindex="0" |
|||
(click)="changePage(totalPages)" |
|||
><span class="ui-paginator-icon pi pi-step-forward"></span |
|||
></a> |
|||
</div> |
|||
@ -0,0 +1,52 @@ |
|||
import { Component, Input, OnInit, Output, EventEmitter, TrackByFunction } from '@angular/core'; |
|||
|
|||
@Component({ |
|||
selector: 'abp-pagination', |
|||
templateUrl: 'pagination.component.html', |
|||
}) |
|||
export class PaginationComponent implements OnInit { |
|||
private _value = 1; |
|||
@Input() |
|||
get value(): number { |
|||
return this._value; |
|||
} |
|||
set value(newValue: number) { |
|||
if (this._value === newValue) return; |
|||
|
|||
this._value = newValue; |
|||
this.valueChange.emit(newValue); |
|||
} |
|||
|
|||
@Output() |
|||
readonly valueChange = new EventEmitter<number>(); |
|||
|
|||
@Input() |
|||
totalPages = 0; |
|||
|
|||
get pageArray(): number[] { |
|||
const count = this.totalPages < 5 ? this.totalPages : 5; |
|||
|
|||
if (this.value === 1 || this.value === 2) { |
|||
return Array.from(new Array(count)).map((_, index) => index + 1); |
|||
} else if (this.value === this.totalPages || this.value === this.totalPages - 1) { |
|||
return Array.from(new Array(count)).map((_, index) => this.totalPages - count + 1 + index); |
|||
} else { |
|||
return [this.value - 2, this.value - 1, this.value, this.value + 1, this.value + 2]; |
|||
} |
|||
} |
|||
|
|||
trackByFn: TrackByFunction<number> = (_, page) => page; |
|||
|
|||
ngOnInit() { |
|||
if (!this.value || this.value < 1 || this.value > this.totalPages) { |
|||
this.value = 1; |
|||
} |
|||
} |
|||
|
|||
changePage(page: number) { |
|||
if (page < 1) return; |
|||
else if (page > this.totalPages) return; |
|||
|
|||
this.value = page; |
|||
} |
|||
} |
|||
@ -0,0 +1,78 @@ |
|||
<div #wrapper class="ui-table ui-widget"> |
|||
<div class="ui-table-wrapper"> |
|||
<ng-container |
|||
*ngTemplateOutlet="scrollable ? scrollableTemplate : defaultTemplate" |
|||
></ng-container> |
|||
<abp-pagination |
|||
*ngIf="rows" |
|||
[totalPages]="totalPages" |
|||
[(value)]="page" |
|||
(valueChange)="pageChange.emit($event)" |
|||
></abp-pagination> |
|||
</div> |
|||
</div> |
|||
|
|||
<ng-template #scrollableTemplate> |
|||
<div class="ui-table-scrollable-wrapper"> |
|||
<div class="ui-table-scrollable-view"></div> |
|||
<div class="ui-table-scrollable-header ui-widget-header"> |
|||
<div [style.margin-left.px]="-bodyScrollLeft" class="ui-table-scrollable-header-box"> |
|||
<table class="ui-table-scrollable-header-table"> |
|||
<ng-container *ngTemplateOutlet="colGroup"></ng-container> |
|||
<ng-container *ngTemplateOutlet="head"></ng-container> |
|||
<tbody></tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
<div |
|||
#scrollableBody |
|||
(scroll)="bodyScrollLeft = scrollableBody.scrollLeft" |
|||
class="ui-table-scrollable-body" |
|||
[style.max-height]="scrollHeight" |
|||
> |
|||
<table class="ui-table-scrollable-body-table"> |
|||
<ng-container *ngTemplateOutlet="colGroup"></ng-container> |
|||
<ng-container *ngTemplateOutlet="body"></ng-container> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</ng-template> |
|||
|
|||
<ng-template #defaultTemplate> |
|||
<table> |
|||
<ng-container *ngTemplateOutlet="colGroup"></ng-container> |
|||
<ng-container *ngTemplateOutlet="head"></ng-container> |
|||
<ng-container *ngTemplateOutlet="body"></ng-container> |
|||
</table> |
|||
</ng-template> |
|||
|
|||
<ng-template #colGroup> |
|||
<ng-container *ngTemplateOutlet="colgroupTemplate"></ng-container> |
|||
</ng-template> |
|||
|
|||
<ng-template #head> |
|||
<thead class="ui-table-thead"> |
|||
<ng-container *ngTemplateOutlet="headerTemplate"></ng-container> |
|||
</thead> |
|||
</ng-template> |
|||
|
|||
<ng-template #body> |
|||
<tbody class="ui-table-tbody"> |
|||
<ng-container *ngIf="value && value.length; else emptyTemplate"> |
|||
<ng-template |
|||
#bodyTemplateWrapper |
|||
*ngFor="let val of slicedValue; let index = index; trackBy: trackByFn" |
|||
[ngTemplateOutlet]="bodyTemplate" |
|||
[ngTemplateOutletContext]="{ $implicit: val, rowIndex: index }" |
|||
></ng-template> |
|||
</ng-container> |
|||
</tbody> |
|||
</ng-template> |
|||
|
|||
<ng-template #emptyTemplate> |
|||
<tr class="empty-row" #emptyRow> |
|||
<div [style.width.px]="emptyRow.offsetWidth"> |
|||
{{ emptyMessage | abpLocalization }} |
|||
</div> |
|||
</tr> |
|||
</ng-template> |
|||
@ -0,0 +1,106 @@ |
|||
import { |
|||
AfterViewInit, |
|||
Component, |
|||
ElementRef, |
|||
EventEmitter, |
|||
Input, |
|||
Output, |
|||
TemplateRef, |
|||
TrackByFunction, |
|||
ViewChild, |
|||
ViewEncapsulation, |
|||
} from '@angular/core'; |
|||
|
|||
@Component({ |
|||
selector: 'abp-table', |
|||
templateUrl: 'table.component.html', |
|||
styles: [ |
|||
` |
|||
.ui-table .ui-table-tbody > tr:nth-child(even):hover, |
|||
.ui-table .ui-table-tbody > tr:hover { |
|||
filter: brightness(90%); |
|||
} |
|||
|
|||
.ui-table .ui-table-tbody > tr.empty-row:hover { |
|||
filter: none; |
|||
} |
|||
|
|||
.ui-table .ui-table-tbody > tr.empty-row > div { |
|||
margin: 10px; |
|||
text-align: center; |
|||
} |
|||
`,
|
|||
], |
|||
encapsulation: ViewEncapsulation.None, |
|||
}) |
|||
export class TableComponent { |
|||
private _totalRecords: number; |
|||
bodyScrollLeft = 0; |
|||
|
|||
@Input() |
|||
value: any[]; |
|||
|
|||
@Input() |
|||
headerTemplate: TemplateRef<any>; |
|||
|
|||
@Input() |
|||
bodyTemplate: TemplateRef<any>; |
|||
|
|||
@Input() |
|||
colgroupTemplate: TemplateRef<any>; |
|||
|
|||
@Input() |
|||
scrollHeight: string; |
|||
|
|||
@Input() |
|||
scrollable: boolean; |
|||
|
|||
@Input() |
|||
rows: number; |
|||
|
|||
@Input() |
|||
page = 1; |
|||
|
|||
@Input() |
|||
trackingProp = 'id'; |
|||
|
|||
@Input() |
|||
emptyMessage = 'AbpAccount::NoDataAvailableInDatatable'; |
|||
|
|||
@Output() |
|||
readonly pageChange = new EventEmitter<number>(); |
|||
|
|||
@ViewChild('wrapper', { read: ElementRef, static: false }) |
|||
wrapperRef: ElementRef<HTMLDivElement>; |
|||
|
|||
@Input() |
|||
get totalRecords(): number { |
|||
return this._totalRecords || this.value.length; |
|||
} |
|||
set totalRecords(newValue: number) { |
|||
if (newValue < 0) this._totalRecords = 0; |
|||
|
|||
this._totalRecords = newValue; |
|||
} |
|||
|
|||
get totalPages(): number { |
|||
if (!this.rows) { |
|||
return; |
|||
} |
|||
|
|||
return Math.ceil(this.totalRecords / this.rows); |
|||
} |
|||
|
|||
get slicedValue(): any[] { |
|||
if (!this.rows || this.rows >= this.value.length) { |
|||
return this.value; |
|||
} |
|||
|
|||
const start = (this.page - 1) * this.rows; |
|||
return this.value.slice(start, start + this.rows); |
|||
} |
|||
|
|||
trackByFn: TrackByFunction<any> = (_, value) => { |
|||
return typeof value === 'object' ? value[this.trackingProp] || value : value; |
|||
}; |
|||
} |
|||
@ -1 +1,2 @@ |
|||
export * from './loading.directive'; |
|||
export * from './table-sort.directive'; |
|||
|
|||
@ -0,0 +1,74 @@ |
|||
import { |
|||
Directive, |
|||
ElementRef, |
|||
AfterViewInit, |
|||
ViewContainerRef, |
|||
ComponentFactoryResolver, |
|||
Input, |
|||
Injector, |
|||
ComponentRef, |
|||
ComponentFactory, |
|||
HostBinding, |
|||
EmbeddedViewRef, |
|||
Renderer2, |
|||
OnInit, |
|||
} from '@angular/core'; |
|||
import { LoadingComponent } from '../components/loading/loading.component'; |
|||
|
|||
@Directive({ selector: '[abpLoading]' }) |
|||
export class LoadingDirective implements OnInit { |
|||
private _loading: boolean; |
|||
|
|||
@HostBinding('style.position') |
|||
position = 'relative'; |
|||
|
|||
@Input('abpLoading') |
|||
get loading(): boolean { |
|||
return this._loading; |
|||
} |
|||
|
|||
set loading(newValue: boolean) { |
|||
setTimeout(() => { |
|||
if (!this.componentRef) { |
|||
this.componentRef = this.cdRes |
|||
.resolveComponentFactory(LoadingComponent) |
|||
.create(this.injector); |
|||
} |
|||
|
|||
if (newValue && !this.rootNode) { |
|||
this.rootNode = (this.componentRef.hostView as EmbeddedViewRef<any>).rootNodes[0]; |
|||
this.targetElement.appendChild(this.rootNode); |
|||
} else { |
|||
this.renderer.removeChild(this.rootNode.parentElement, this.rootNode); |
|||
this.rootNode = null; |
|||
} |
|||
|
|||
this._loading = newValue; |
|||
}, 0); |
|||
} |
|||
|
|||
@Input('abpLoadingTargetElement') |
|||
targetElement: HTMLElement; |
|||
|
|||
componentRef: ComponentRef<LoadingComponent>; |
|||
rootNode: HTMLDivElement; |
|||
|
|||
constructor( |
|||
private elRef: ElementRef<HTMLElement>, |
|||
private vcRef: ViewContainerRef, |
|||
private cdRes: ComponentFactoryResolver, |
|||
private injector: Injector, |
|||
private renderer: Renderer2, |
|||
) {} |
|||
|
|||
ngOnInit() { |
|||
if (!this.targetElement) { |
|||
const { offsetHeight, offsetWidth } = this.elRef.nativeElement; |
|||
if (!offsetHeight && !offsetWidth && this.elRef.nativeElement.children.length) { |
|||
this.targetElement = this.elRef.nativeElement.children[0] as HTMLElement; |
|||
} else { |
|||
this.targetElement = this.elRef.nativeElement; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,82 @@ |
|||
import { SpectatorDirective, createDirectiveFactory } from '@ngneat/spectator/jest'; |
|||
import { LoadingDirective } from '../directives'; |
|||
import { LoadingComponent } from '../components'; |
|||
|
|||
import { Component } from '@angular/core'; |
|||
|
|||
@Component({ |
|||
selector: 'abp-dummy', |
|||
template: '<div id="dummy">Testing Loading Directive</div>', |
|||
}) |
|||
export class DummyComponent {} |
|||
|
|||
describe('LoadingDirective', () => { |
|||
let spectator: SpectatorDirective<LoadingDirective>; |
|||
const createDirective = createDirectiveFactory({ |
|||
directive: LoadingDirective, |
|||
declarations: [LoadingComponent, DummyComponent], |
|||
entryComponents: [LoadingComponent], |
|||
}); |
|||
|
|||
describe('default', () => { |
|||
beforeEach(() => { |
|||
spectator = createDirective('<div [abpLoading]="status">Testing Loading Directive</div>', { |
|||
hostProps: { status: true }, |
|||
}); |
|||
}); |
|||
|
|||
it('should create the loading component', done => { |
|||
setTimeout(() => { |
|||
expect(spectator.directive.rootNode).toBeTruthy(); |
|||
expect(spectator.directive.componentRef).toBeTruthy(); |
|||
done(); |
|||
}, 0); |
|||
}); |
|||
}); |
|||
|
|||
describe('with custom target', () => { |
|||
const mockTarget = document.createElement('div'); |
|||
const spy = jest.spyOn(mockTarget, 'appendChild'); |
|||
|
|||
beforeEach(() => { |
|||
spectator = createDirective( |
|||
'<div [abpLoading]="status" [abpLoadingTargetElement]="target">Testing Loading Directive</div>', |
|||
{ |
|||
hostProps: { status: true, target: mockTarget }, |
|||
}, |
|||
); |
|||
}); |
|||
|
|||
it('should add the loading component to the DOM', done => { |
|||
setTimeout(() => { |
|||
expect(spy).toHaveBeenCalled(); |
|||
done(); |
|||
}, 0); |
|||
}); |
|||
|
|||
it('should remove the loading component to the DOM', done => { |
|||
const rendererSpy = jest.spyOn(spectator.directive['renderer'], 'removeChild'); |
|||
spectator.setHostInput({ status: false }); |
|||
setTimeout(() => { |
|||
expect(rendererSpy).toHaveBeenCalled(); |
|||
expect(spectator.directive.rootNode).toBeFalsy(); |
|||
done(); |
|||
}, 0); |
|||
}); |
|||
}); |
|||
|
|||
describe('with a component selector', () => { |
|||
beforeEach(() => { |
|||
spectator = createDirective('<abp-dummy [abpLoading]="status"></abp-dummy>', { |
|||
hostProps: { status: true }, |
|||
}); |
|||
}); |
|||
|
|||
it('should select the child element', done => { |
|||
setTimeout(() => { |
|||
expect(spectator.directive.targetElement.id).toBe('dummy'); |
|||
done(); |
|||
}, 0); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,63 @@ |
|||
import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; |
|||
import { PaginationComponent } from '../components'; |
|||
|
|||
describe('PaginationComponent', () => { |
|||
let spectator: SpectatorHost<PaginationComponent>; |
|||
const createHost = createHostFactory({ |
|||
component: PaginationComponent, |
|||
}); |
|||
|
|||
beforeEach(() => { |
|||
spectator = createHost( |
|||
'<abp-pagination [totalPages]="totalPages" [value]="value"></abp-pagination>', |
|||
{ |
|||
hostProps: { |
|||
value: 5, |
|||
totalPages: 12, |
|||
}, |
|||
}, |
|||
); |
|||
}); |
|||
|
|||
it('should add ui-state-active class to current page', () => { |
|||
expect(spectator.query('.ui-state-active').textContent).toBe('5'); |
|||
}); |
|||
|
|||
it('should display the correct pages', () => { |
|||
expect(spectator.queryAll('.ui-paginator-page').map(node => node.textContent)).toEqual([ |
|||
'3', |
|||
'4', |
|||
'5', |
|||
'6', |
|||
'7', |
|||
]); |
|||
|
|||
spectator.click('.ui-paginator-first'); |
|||
|
|||
expect(spectator.queryAll('.ui-paginator-page').map(node => node.textContent)).toEqual([ |
|||
'1', |
|||
'2', |
|||
'3', |
|||
'4', |
|||
'5', |
|||
]); |
|||
|
|||
spectator.setHostInput({ value: 12 }); |
|||
|
|||
expect(spectator.queryAll('.ui-paginator-page').map(node => node.textContent)).toEqual([ |
|||
'8', |
|||
'9', |
|||
'10', |
|||
'11', |
|||
'12', |
|||
]); |
|||
|
|||
spectator.setHostInput({ value: 1, totalPages: 3 }); |
|||
|
|||
expect(spectator.queryAll('.ui-paginator-page').map(node => node.textContent)).toEqual([ |
|||
'1', |
|||
'2', |
|||
'3', |
|||
]); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,74 @@ |
|||
import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; |
|||
import { PaginationComponent, TableComponent } from '../components'; |
|||
|
|||
import { Pipe, PipeTransform } from '@angular/core'; |
|||
|
|||
@Pipe({ |
|||
name: 'abpLocalization', |
|||
}) |
|||
export class DummyLocalizationPipe implements PipeTransform { |
|||
transform(value: any, ...args: any[]): any { |
|||
return value; |
|||
} |
|||
} |
|||
|
|||
describe('TableComponent', () => { |
|||
let spectator: SpectatorHost<TableComponent>; |
|||
const createHost = createHostFactory({ |
|||
component: TableComponent, |
|||
declarations: [PaginationComponent, DummyLocalizationPipe], |
|||
}); |
|||
|
|||
describe('without value', () => { |
|||
beforeEach(() => { |
|||
spectator = createHost( |
|||
`<abp-table
|
|||
[headerTemplate]="header" |
|||
[colgroupTemplate]="colgroup" |
|||
[value]="value"> |
|||
</abp-table> |
|||
<ng-template #colgroup><colgroup><col /></colgroup></ng-template> |
|||
<ng-template #header><th>name</th></ng-template>`, |
|||
{ |
|||
hostProps: { |
|||
value: [], |
|||
}, |
|||
}, |
|||
); |
|||
}); |
|||
|
|||
it('should display the empty message', () => { |
|||
expect(spectator.query('tr.empty-row>div')).toHaveText( |
|||
'AbpAccount::NoDataAvailableInDatatable', |
|||
); |
|||
}); |
|||
|
|||
it('should display the header', () => { |
|||
expect(spectator.query('thead')).toBeTruthy(); |
|||
expect(spectator.query('th')).toHaveText('name'); |
|||
}); |
|||
|
|||
it('should place the colgroup template', () => { |
|||
expect(spectator.query('colgroup')).toBeTruthy(); |
|||
expect(spectator.query('col')).toBeTruthy(); |
|||
}); |
|||
}); |
|||
|
|||
describe('with value', () => { |
|||
// TODO
|
|||
beforeEach(() => { |
|||
spectator = createHost( |
|||
`<abp-table
|
|||
[headerTemplate]="header" |
|||
[value]="value"></abp-table> |
|||
<ng-template #header><th>name</th></ng-template> |
|||
`,
|
|||
{ |
|||
hostProps: { |
|||
value: [], |
|||
}, |
|||
}, |
|||
); |
|||
}); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue