diff --git a/demo/src/app/app.module.ts b/demo/src/app/app.module.ts
index 437503825d..01aeae5c32 100644
--- a/demo/src/app/app.module.ts
+++ b/demo/src/app/app.module.ts
@@ -23,6 +23,7 @@ import { DemoPaginationModule } from './components/pagination';
import { DemoPopoverModule } from './components/popover/index';
import { DemoProgressbarModule } from './components/progressbar';
import { DemoRatingModule } from './components/rating';
+import { DemoSortableModule } from './components/sortable';
import { DemoTabsModule } from './components/tabs';
import { DemoTimepickerModule } from './components/timepicker/index';
import { DemoTooltipModule } from './components/tooltip/index';
@@ -59,6 +60,7 @@ import { ngdoc } from '../ng-api-doc';
DemoPopoverModule,
DemoProgressbarModule,
DemoRatingModule,
+ DemoSortableModule,
DemoTabsModule,
DemoTimepickerModule,
DemoTooltipModule,
diff --git a/demo/src/app/app.routing.ts b/demo/src/app/app.routing.ts
index 5c9b66e696..5ae54d871b 100644
--- a/demo/src/app/app.routing.ts
+++ b/demo/src/app/app.routing.ts
@@ -10,6 +10,7 @@ import { ModalSectionComponent } from './components/modal/modal-section.componen
import { ProgressbarSectionComponent } from './components/progressbar/progressbar-section.component';
import { PaginationSectionComponent } from './components/pagination/pagination-section.component';
import { RatingSectionComponent } from './components/rating/rating-section.component';
+import { SortableSectionComponent } from './components/sortable/sortable-section.component';
import { TabsSectionComponent } from './components/tabs/tabs-section.component';
import { TimepickerSectionComponent } from './components/timepicker/timepicker-section.component';
import { TooltipSectionComponent } from './components/tooltip/tooltip-section.component';
@@ -92,6 +93,11 @@ export const routes = [
data: ['Timepicker'],
component: TimepickerSectionComponent
},
+ {
+ path: 'sortable',
+ data: ['Sortable'],
+ component: SortableSectionComponent
+ },
{
path: 'tooltip',
data: ['Tooltip'],
diff --git a/demo/src/app/components/sortable/demos/index.ts b/demo/src/app/components/sortable/demos/index.ts
new file mode 100644
index 0000000000..2ce994b572
--- /dev/null
+++ b/demo/src/app/components/sortable/demos/index.ts
@@ -0,0 +1,10 @@
+import { SortableDemoComponent } from './sortable-demo.component';
+
+export const DEMO_COMPONENTS = [SortableDemoComponent];
+
+export const DEMOS = {
+ basic: {
+ component: require('!!raw?lang=typescript!./sortable-demo.component.ts'),
+ html: require('!!raw?lang=markup!./sortable-demo.component.html')
+ }
+};
diff --git a/demo/src/app/components/sortable/demos/sortable-demo.component.html b/demo/src/app/components/sortable/demos/sortable-demo.component.html
new file mode 100644
index 0000000000..4201b7787c
--- /dev/null
+++ b/demo/src/app/components/sortable/demos/sortable-demo.component.html
@@ -0,0 +1,63 @@
+
+
+
+
String items:
+
+
+
+
model: {{ itemStringsLeft | json }}
+
+
+
+
model: {{ itemStringsRight | json }}
+
+
+
+
+
+
+
Complex data model:
+
+
+
+
model: {{ itemObjectsLeft | json }}
+
+
+
+
model: {{ itemObjectsRight | json }}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/demo/src/app/components/sortable/demos/sortable-demo.component.ts b/demo/src/app/components/sortable/demos/sortable-demo.component.ts
new file mode 100644
index 0000000000..4dd631b475
--- /dev/null
+++ b/demo/src/app/components/sortable/demos/sortable-demo.component.ts
@@ -0,0 +1,56 @@
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'sortable-demo',
+ templateUrl: './sortable-demo.component.html'
+})
+export class SortableDemoComponent {
+ public itemStringsLeft: any[] = [
+ 'Windstorm',
+ 'Bombasto',
+ 'Magneta',
+ 'Tornado'
+ ];
+
+ public itemStringsRight: any[] = [
+ 'Mr. O',
+ 'Tomato'
+ ];
+
+ public itemObjectsLeft: any[] = [
+ { id: 1, name: 'Windstorm' },
+ { id: 2, name: 'Bombasto' },
+ { id: 3, name: 'Magneta' }
+ ];
+
+ public itemObjectsRight: any[] = [
+ { id: 4, name: 'Tornado' },
+ { id: 5, name: 'Mr. O' },
+ { id: 6, name: 'Tomato' }
+ ];
+
+ public itemStyle: {} = {
+ display: 'block',
+ padding: '6px 12px',
+ 'margin-bottom': '4px',
+ 'font-size': '14px',
+ 'font-weight': 400,
+ 'line-height': '1.4em',
+ 'text-align': 'center',
+ cursor: 'grab',
+ border: '1px solid transparent',
+ 'border-radius': '4px',
+ 'border-color': '#adadad'
+ };
+
+ public itemActiveStyle: {} = {
+ 'background-color': '#e6e6e6',
+ 'box-shadow': 'inset 0 3px 5px rgba(0,0,0,.125)'
+ };
+
+ public wrapperStyle: {} = {
+ 'min-height': '150px'
+ };
+
+ public placeholderStyle: {} = Object.assign({}, this.itemStyle, { height: '150px' });
+}
diff --git a/demo/src/app/components/sortable/docs/title.md b/demo/src/app/components/sortable/docs/title.md
new file mode 100644
index 0000000000..84b921c38a
--- /dev/null
+++ b/demo/src/app/components/sortable/docs/title.md
@@ -0,0 +1 @@
+The **sortable component** represents a list of items, with ability to sort them or move to another container via drag&drop. Input collection isn't mutated by the component, so events ngModelChange
, onChange
are using new collections.
diff --git a/demo/src/app/components/sortable/docs/usage.md b/demo/src/app/components/sortable/docs/usage.md
new file mode 100644
index 0000000000..e016e1871b
--- /dev/null
+++ b/demo/src/app/components/sortable/docs/usage.md
@@ -0,0 +1,11 @@
+```typescript
+// RECOMMENDED
+import { SortableModule } from 'ng2-bootstrap/sortable';
+// or
+import { SortableModule } from 'ng2-bootstrap';
+
+@NgModule({
+ imports: [SortableModule,...]
+})
+export class AppModule(){}
+```
\ No newline at end of file
diff --git a/demo/src/app/components/sortable/index.ts b/demo/src/app/components/sortable/index.ts
new file mode 100644
index 0000000000..6e942d5f63
--- /dev/null
+++ b/demo/src/app/components/sortable/index.ts
@@ -0,0 +1,24 @@
+import { NgModule } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { CommonModule } from '@angular/common';
+import { SharedModule } from '../../shared';
+
+import { SortableSectionComponent } from './sortable-section.component';
+import { DEMO_COMPONENTS } from './demos';
+import { SortableModule } from 'ng2-bootstrap/sortable';
+
+@NgModule({
+ declarations: [
+ SortableSectionComponent,
+ ...DEMO_COMPONENTS
+ ],
+ imports: [
+ CommonModule,
+ FormsModule,
+ SharedModule,
+ SortableModule
+ ],
+ exports: [SortableSectionComponent]
+})
+export class DemoSortableModule {
+}
diff --git a/demo/src/app/components/sortable/sortable-section.component.html b/demo/src/app/components/sortable/sortable-section.component.html
new file mode 100644
index 0000000000..d466c865c8
--- /dev/null
+++ b/demo/src/app/components/sortable/sortable-section.component.html
@@ -0,0 +1,25 @@
+
+ Contents
+
+
+ Usage
+
+
+
+ Examples
+
+
+
+
+
+ API Reference
+
+
\ No newline at end of file
diff --git a/demo/src/app/components/sortable/sortable-section.component.ts b/demo/src/app/components/sortable/sortable-section.component.ts
new file mode 100644
index 0000000000..b1b98e2972
--- /dev/null
+++ b/demo/src/app/components/sortable/sortable-section.component.ts
@@ -0,0 +1,19 @@
+import { Component } from '@angular/core';
+
+import { DEMOS } from './demos';
+
+// webpack html imports
+let titleDoc = require('html!markdown!./docs/title.md');
+let usageDoc = require('html!markdown!./docs/usage.md');
+
+@Component({
+ selector: 'sortable-section',
+ templateUrl: './sortable-section.component.html'
+})
+export class SortableSectionComponent {
+ public name:string = 'Sortable';
+ public src:string = 'https://github.com/valor-software/ng2-bootstrap/blob/master/components/sortable';
+ public titleDoc:string = titleDoc;
+ public usageDoc:string = usageDoc;
+ public demos: any = DEMOS;
+}
diff --git a/demo/src/ng-api-doc.ts b/demo/src/ng-api-doc.ts
index 20565c4119..c02c791d88 100644
--- a/demo/src/ng-api-doc.ts
+++ b/demo/src/ng-api-doc.ts
@@ -1445,6 +1445,93 @@ export const ngdoc = {
"properties": [],
"methods": []
},
+ "DraggableItemService": {
+ "fileName": "src/sortable/draggable-item.service.ts",
+ "className": "DraggableItemService",
+ "description": "",
+ "methods": [],
+ "properties": []
+ },
+ "DraggableItem": {
+ "fileName": "src/sortable/draggable-item.ts",
+ "className": "DraggableItem",
+ "description": "",
+ "methods": [],
+ "properties": []
+ },
+ "SortableComponent": {
+ "fileName": "src/sortable/sortable.component.ts",
+ "className": "SortableComponent",
+ "description": "",
+ "selector": "ng2-sortable",
+ "inputs": [
+ {
+ "name": "fieldName",
+ "type": "string",
+ "description": "field name if input array consists of objects
\n"
+ },
+ {
+ "name": "itemActiveClass",
+ "type": "string",
+ "description": "class name for active item
\n"
+ },
+ {
+ "name": "itemActiveStyle",
+ "type": "{ [key: string]: string; }",
+ "description": "style object for active item
\n"
+ },
+ {
+ "name": "itemClass",
+ "type": "string",
+ "description": "class name for item
\n"
+ },
+ {
+ "name": "itemStyle",
+ "type": "{ [key: string]: string; }",
+ "description": "style object for item
\n"
+ },
+ {
+ "name": "placeholderClass",
+ "type": "string",
+ "description": "class name for placeholder
\n"
+ },
+ {
+ "name": "placeholderItem",
+ "type": "string",
+ "description": "placeholder item which will be shown if collection is empty
\n"
+ },
+ {
+ "name": "placeholderStyle",
+ "type": "{ [key: string]: string; }",
+ "description": "style object for placeholder
\n"
+ },
+ {
+ "name": "wrapperClass",
+ "type": "string",
+ "description": "class name for items wrapper
\n"
+ },
+ {
+ "name": "wrapperStyle",
+ "type": "{ [key: string]: string; }",
+ "description": "style object for items wrapper
\n"
+ }
+ ],
+ "outputs": [
+ {
+ "name": "onChange",
+ "description": "fired on array change (reordering, insert, remove), same as ngModelChange
.\n Returns new items collection as a payload.
\n"
+ }
+ ],
+ "properties": [],
+ "methods": []
+ },
+ "SortableItem": {
+ "fileName": "src/sortable/sortable.component.ts",
+ "className": "SortableItem",
+ "description": "",
+ "methods": [],
+ "properties": []
+ },
"NgTranscludeDirective": {
"fileName": "src/tabs/ng-transclude.directive.ts",
"className": "NgTranscludeDirective",
diff --git a/src/index.ts b/src/index.ts
index 15759db93b..32c75ccba1 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -58,6 +58,7 @@ export * from './dropdown';
export * from './pagination';
export * from './progressbar';
export * from './rating';
+export * from './sortable';
export * from './tabs';
export * from './timepicker';
export * from './tooltip';
diff --git a/src/pagination/pager.component.ts b/src/pagination/pager.component.ts
index 5bd0eaa5a1..ffb7ce08bb 100644
--- a/src/pagination/pager.component.ts
+++ b/src/pagination/pager.component.ts
@@ -123,7 +123,7 @@ export class PagerComponent implements ControlValueAccessor, OnInit {
protected _totalItems: number;
protected _totalPages: number;
protected inited: boolean = false;
- protected _page: number;
+ protected _page: number = 1;
public constructor(renderer: Renderer, elementRef: ElementRef, paginationConfig: PaginationConfig) {
this.renderer = renderer;
diff --git a/src/pagination/pagination.component.ts b/src/pagination/pagination.component.ts
index 14279c1d56..5b785e58d4 100644
--- a/src/pagination/pagination.component.ts
+++ b/src/pagination/pagination.component.ts
@@ -150,7 +150,7 @@ export class PaginationComponent implements ControlValueAccessor, OnInit {
protected _totalItems:number;
protected _totalPages:number;
protected inited:boolean = false;
- protected _page:number;
+ protected _page:number = 1;
public constructor(renderer:Renderer, elementRef:ElementRef, paginationConfig: PaginationConfig) {
this.renderer = renderer;
diff --git a/src/sortable/draggable-item.service.ts b/src/sortable/draggable-item.service.ts
new file mode 100644
index 0000000000..c4af11a9fe
--- /dev/null
+++ b/src/sortable/draggable-item.service.ts
@@ -0,0 +1,37 @@
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs/Observable';
+import { Subject } from 'rxjs/Subject';
+import { DraggableItem } from './draggable-item';
+
+@Injectable()
+export class DraggableItemService {
+ private draggableItem: DraggableItem;
+
+ private onCapture: Subject = new Subject();
+
+ public dragStart(item: DraggableItem): void {
+ this.draggableItem = item;
+ }
+
+ public getItem(): DraggableItem {
+ return this.draggableItem;
+ }
+
+ public captureItem(overZoneIndex: number, newIndex: number): DraggableItem {
+ if (this.draggableItem.overZoneIndex !== overZoneIndex) {
+ this.draggableItem.lastZoneIndex = this.draggableItem.overZoneIndex;
+ this.draggableItem.overZoneIndex = overZoneIndex;
+ this.onCapture.next(this.draggableItem);
+ this.draggableItem = Object.assign(
+ {},
+ this.draggableItem,
+ { overZoneIndex, i: newIndex }
+ );
+ }
+ return this.draggableItem;
+ }
+
+ public onCaptureItem(): Observable {
+ return this.onCapture;
+ }
+}
diff --git a/src/sortable/draggable-item.ts b/src/sortable/draggable-item.ts
new file mode 100644
index 0000000000..0fdbe47d86
--- /dev/null
+++ b/src/sortable/draggable-item.ts
@@ -0,0 +1,8 @@
+export interface DraggableItem {
+ event: DragEvent;
+ item: any;
+ i: number;
+ initialIndex: number;
+ lastZoneIndex: number;
+ overZoneIndex: number;
+}
diff --git a/src/sortable/index.ts b/src/sortable/index.ts
new file mode 100644
index 0000000000..d2b23b21cb
--- /dev/null
+++ b/src/sortable/index.ts
@@ -0,0 +1,4 @@
+export * from './sortable.module';
+export * from './sortable.component';
+export * from './draggable-item.service';
+export * from './draggable-item';
diff --git a/src/sortable/sortable.component.ts b/src/sortable/sortable.component.ts
new file mode 100644
index 0000000000..4dc24599f8
--- /dev/null
+++ b/src/sortable/sortable.component.ts
@@ -0,0 +1,226 @@
+import { Component, Input, Output, EventEmitter, Inject, forwardRef, animate, style, state, transition, keyframes, trigger } from '@angular/core';
+import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms';
+import 'rxjs/add/operator/first';
+
+import { DraggableItem } from './draggable-item';
+import { DraggableItemService } from './draggable-item.service';
+
+const nullCallback = (arg?: any): void => { return void 0; };
+
+/* tslint:disable */
+@Component({
+ selector: 'ng2-sortable',
+ template: `
+
+
{{placeholderItem}}
+
{{item.value}}
+
`,
+ providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SortableComponent), multi: true }],
+ animations: [
+ trigger('flyInOut', [
+ state('in', style({ height: '*' })),
+ transition('void => *', [
+ style({ height: 0 }),
+ animate('100ms ease-out')
+ ]),
+ transition('* => void', [
+ style({ height: '*' }),
+ animate('100ms ease-out', style({ height: 0 }))
+ ])
+ ])
+ ]
+})
+/* tslint:enable */
+export class SortableComponent implements ControlValueAccessor {
+ private static globalZoneIndex: number = 0;
+
+ /** field name if input array consists of objects */
+ @Input() public fieldName: string;
+
+ /** class name for items wrapper */
+ @Input() public wrapperClass: string = '';
+
+ /** style object for items wrapper */
+ @Input() public wrapperStyle: { [key: string]: string } = {};
+
+ /** class name for item */
+ @Input() public itemClass: string = '';
+
+ /** style object for item */
+ @Input() public itemStyle: { [key: string]: string } = {};
+
+ /** class name for active item */
+ @Input() public itemActiveClass: string = '';
+
+ /** style object for active item */
+ @Input() public itemActiveStyle: { [key: string]: string } = {};
+
+ /** class name for placeholder */
+ @Input() public placeholderClass: string = '';
+
+ /** style object for placeholder */
+ @Input() public placeholderStyle: { [key: string]: string } = {};
+
+ /** placeholder item which will be shown if collection is empty */
+ @Input() public placeholderItem: string = '';
+
+ /** fired on array change (reordering, insert, remove), same as ngModelChange
.
+ * Returns new items collection as a payload.
+ */
+ @Output() public onChange: EventEmitter = new EventEmitter();
+
+ private _items: SortableItem[];
+
+ private showPlaceholder: boolean = false;
+
+ private get items(): SortableItem[] {
+ return this._items;
+ }
+
+ private set items(value: SortableItem[]) {
+ this._items = value;
+ let out = this.items.map((x: SortableItem) => x.initData);
+ this.onChanged(out);
+ this.onChange.emit(out);
+ }
+
+ private onTouched: () => void = nullCallback;
+ private onChanged: (_: any) => void = nullCallback;
+
+ private transfer: DraggableItemService;
+ private currentZoneIndex: number;
+ private activeItem: number = -1;
+
+ public constructor(transfer: DraggableItemService) {
+ this.transfer = transfer;
+ this.currentZoneIndex = SortableComponent.globalZoneIndex++;
+ this.transfer.onCaptureItem().subscribe((item: DraggableItem) => this.onDrop(item));
+ }
+
+ public onItemDragstart(event: DragEvent, item: SortableItem, i: number): void {
+ this.initDragstartEvent(event);
+ this.onTouched();
+ this.transfer.dragStart({
+ event,
+ item,
+ i,
+ initialIndex: i,
+ lastZoneIndex: this.currentZoneIndex,
+ overZoneIndex: this.currentZoneIndex
+ });
+ }
+
+ public onItemDragover(event: DragEvent, i: number): void {
+ if (!this.transfer.getItem()) {
+ return;
+ }
+ event.preventDefault();
+ let dragItem = this.transfer.captureItem(this.currentZoneIndex, this.items.length);
+ let newArray: any[] = [];
+ if (!this.items.length) {
+ newArray = [ dragItem.item ];
+ } else if (dragItem.i > i) {
+ newArray = [
+ ...this.items.slice(0, i),
+ dragItem.item,
+ ...this.items.slice(i, dragItem.i),
+ ...this.items.slice(dragItem.i + 1)
+ ];
+ } else { // this.draggedItem.i < i
+ newArray = [
+ ...this.items.slice(0, dragItem.i),
+ ...this.items.slice(dragItem.i + 1, i + 1),
+ dragItem.item,
+ ...this.items.slice(i + 1)
+ ];
+ }
+ this.items = newArray;
+ dragItem.i = i;
+ this.activeItem = i;
+ }
+
+ public cancelEvent(event: DragEvent): void {
+ if (!this.transfer.getItem() || !event) {
+ return;
+ }
+ event.preventDefault();
+ }
+
+ public onDrop(item: DraggableItem): void {
+ if (item &&
+ item.overZoneIndex !== this.currentZoneIndex &&
+ item.lastZoneIndex === this.currentZoneIndex
+ ) {
+ this.items = this.items.filter((x: SortableItem, i: number) => i !== item.i);
+ }
+ this.resetActiveItem(undefined);
+ }
+
+ public resetActiveItem(event: DragEvent): void {
+ this.cancelEvent(event);
+ this.activeItem = -1;
+ }
+
+ public registerOnChange(callback: (_: any) => void): void {
+ this.onChanged = callback;
+ }
+
+ public registerOnTouched(callback: () => void): void {
+ this.onTouched = callback;
+ }
+
+ public writeValue(value: any[]): void {
+ if (value) {
+ this.items = value.map((x: any, i: number) => ({ id: i, initData: x, value: this.fieldName ? x[this.fieldName] : x }));
+ } else {
+ this.items = [];
+ }
+ this.updatePlaceholderState();
+ }
+
+ public updatePlaceholderState(): void {
+ this.showPlaceholder = !this._items.length;
+ }
+
+ public getItemStyle(isActive: boolean): {} {
+ return isActive ? Object.assign({}, this.itemStyle, this.itemActiveStyle) : this.itemStyle;
+ }
+
+ private initDragstartEvent(event: DragEvent): void {
+ // it is necessary for mozilla
+ // data type should be 'Text' instead of 'text/plain' to keep compatibility with IE
+ event.dataTransfer.setData('Text', 'placeholder');
+ }
+}
+
+export declare interface SortableItem {
+ id: number;
+ value: string;
+ initData: any;
+}
diff --git a/src/sortable/sortable.module.ts b/src/sortable/sortable.module.ts
new file mode 100644
index 0000000000..3cfa2b1a54
--- /dev/null
+++ b/src/sortable/sortable.module.ts
@@ -0,0 +1,22 @@
+import { NgModule } from '@angular/core';
+import { BrowserModule } from '@angular/platform-browser';
+
+import { SortableComponent } from './sortable.component';
+import { DraggableItemService } from './draggable-item.service';
+
+@NgModule({
+ declarations: [
+ SortableComponent
+ ],
+ imports: [
+ BrowserModule
+ ],
+ exports: [
+ BrowserModule,
+ SortableComponent
+ ],
+ providers: [
+ DraggableItemService
+ ]
+})
+export class SortableModule { }
diff --git a/src/spec/draggable-item.service.spec.ts b/src/spec/draggable-item.service.spec.ts
new file mode 100644
index 0000000000..e48b7fda54
--- /dev/null
+++ b/src/spec/draggable-item.service.spec.ts
@@ -0,0 +1,80 @@
+import { TestBed, fakeAsync, inject } from '@angular/core/testing';
+import { Component } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { DraggableItemService } from '../sortable';
+import { DraggableItem } from '../sortable';
+import { SortableItem } from '../sortable';
+
+@Component({
+ template: `Test
`
+})
+class TestComponent {}
+
+describe('Service: DraggableItem', () => {
+ let transfer: DraggableItemService;
+ let draggableItem: DraggableItem;
+
+ beforeEach(fakeAsync(() => {
+ TestBed.configureTestingModule({
+ declarations: [TestComponent],
+ providers: [DraggableItemService]
+ }).createComponent(TestComponent);
+ }));
+
+ beforeEach(inject([DraggableItemService], (service: DraggableItemService) => {
+ draggableItem = getDraggableItem(getItemToDrag(), undefined, 1);
+ transfer = service;
+ transfer.dragStart(draggableItem);
+ }));
+
+ it('should return draggable item', () => {
+ // arrange
+ // act
+ let item = transfer.getItem();
+
+ // assert
+ expect(item).toBe(draggableItem);
+ });
+
+ it('should fire onCapture if item was captured by another zone', () => {
+ // arrange
+ let spy = spyOn(transfer.onCaptureItem(), 'next');
+
+ // act
+ let item = transfer.captureItem(2, 0);
+
+ // assert
+ expect(spy).toHaveBeenCalledWith(draggableItem);
+ });
+
+ it('should NOT fire onCapture if item was captured by the same zone', () => {
+ // arrange
+ let spy = spyOn(transfer.onCaptureItem(), 'next');
+
+ // act
+ let item = transfer.captureItem(1, 0);
+
+ // assert
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ function getItemToDrag(): SortableItem {
+ return { id: 0, value: 'item text', initData: 'item text'};
+ }
+
+ function getDraggableItem(
+ sortableItem: SortableItem,
+ dragEvent: DragEvent,
+ zone: number
+ ): DraggableItem {
+ return {
+ event: dragEvent,
+ item: sortableItem,
+ i: 0,
+ initialIndex: 0,
+ lastZoneIndex: zone,
+ overZoneIndex: zone
+ };
+ }
+});
diff --git a/src/spec/sortable.component.spec.ts b/src/spec/sortable.component.spec.ts
new file mode 100644
index 0000000000..a4ad737280
--- /dev/null
+++ b/src/spec/sortable.component.spec.ts
@@ -0,0 +1,298 @@
+import { ComponentFixture, TestBed, fakeAsync, tick, ComponentFixtureAutoDetect, inject } from '@angular/core/testing';
+import { Component, DebugElement } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { SortableModule, SortableComponent, DraggableItemService } from '../sortable';
+import { DraggableItem } from '../sortable';
+import { SortableItem } from '../sortable';
+
+const HEROES: string[] = [ 'Windstorm', 'Bombasto', 'Magneta', 'Tornado' ];
+const HEROES_OBJ: any[] = [ { id: 1, name: 'Windstorm' }, { id: 2, name: 'Bombasto' }, { id: 3, name: 'Magneta' } ];
+
+@Component({
+ template: `
+
+
+`
+})
+class TestSortableComponent {
+ public selectedState:string;
+ public heroes: string[] = [...HEROES];
+ public heroesObj: any[] = [...HEROES_OBJ];
+}
+
+describe('Component: Sortable', () => {
+ let fixture: ComponentFixture;
+ let sort1: SortableComponent;
+ let sort2: SortableComponent;
+
+ beforeEach(fakeAsync(() => {
+ fixture = TestBed.configureTestingModule({
+ declarations: [ TestSortableComponent ],
+ imports: [ SortableModule, FormsModule ],
+ providers: [{ provide: ComponentFixtureAutoDetect, useValue: true }]
+ }).createComponent(TestSortableComponent);
+
+ fixture.detectChanges();
+
+ let sortableComponents = fixture.debugElement.queryAll(By.directive(SortableComponent)).map((de:DebugElement) => de.injector.get(SortableComponent) as SortableComponent);
+ [ sort1, sort2 ] = sortableComponents;
+ }));
+
+ it('should be defined on the test component', () => {
+ expect(sort1).not.toBeNull('sortable component with strings');
+ expect(sort2).not.toBeNull('sortable component with objects');
+ });
+
+ it('different zones should have different ids', () => {
+ expect((sort1 as any).currentZoneIndex).not.toBe((sort2 as any).currentZoneIndex);
+ });
+
+ describe('onChange', () => {
+ it('should render list of strings', fakeAsync(() => {
+ // arrange
+ // act
+ let renderedItems = getItemsByContainerId();
+ // assert
+ expect(renderedItems).toEqual(HEROES);
+ }));
+
+ it('should render list of complex models', () => {
+ // arrange
+ // act
+ let renderedItems = getItemsByContainerId('sort2');
+ // assert
+ expect(renderedItems).toEqual(HEROES_OBJ.map((h: any) => h.name));
+ });
+ });
+
+ it('should apply active item style over item style', () => {
+ // arrange
+ let activeItemStyle = Object.assign({}, sort1.itemStyle, sort1.itemActiveStyle);
+ // act
+ let style = sort1.getItemStyle(true);
+ // assert
+ expect(style).toEqual(activeItemStyle);
+ });
+
+ it('should return normal item style', () => {
+ // arrange
+ let normalItemStyle = Object.assign({}, sort1.itemStyle);
+ // act
+ let style = sort1.getItemStyle(false);
+ // assert
+ expect(style).toEqual(normalItemStyle);
+ });
+
+ describe('process drag & drop', () => {
+ let transfer: DraggableItemService;
+ let item: SortableItem;
+ let event: DragEvent;
+ let draggableItem: DraggableItem;
+ let spyOnChanged: jasmine.Spy;
+ let spyGetItem: jasmine.Spy;
+ let spyCaptureItem: jasmine.Spy;
+ let sort1ZoneNumber: number;
+ let spyPreventDefault: jasmine.Spy;
+ let spyOnDrop: jasmine.Spy;
+
+ beforeEach(inject([DraggableItemService], (service: DraggableItemService) => {
+ transfer = service;
+ item = getItemToDrag();
+ event = { preventDefault: () => void 0, dataTransfer: { setData: () => void 0 } as any } as DragEvent;
+ sort1ZoneNumber = (sort1 as any).currentZoneIndex;
+ draggableItem = getDraggableItem(item, event, sort1ZoneNumber);
+ spyOnChanged = spyOn(sort1, 'onChanged');
+ spyGetItem = spyOn(transfer, 'getItem').and.returnValue(draggableItem);
+ spyCaptureItem = spyOn(transfer, 'captureItem').and.returnValue(draggableItem);
+ spyPreventDefault = spyOn(event, 'preventDefault');
+ spyOnDrop = spyOn(sort1, 'onDrop').and.callThrough();
+ }));
+
+ it('should pass dragged item to transfer', () => {
+ // arrange
+ let spy = spyOn(transfer, 'dragStart');
+ // act
+ sort1.onItemDragstart(event, item, 0);
+ // assert
+ expect(spy).toHaveBeenCalledWith(getDraggableItem(item, event, sort1ZoneNumber));
+ });
+
+ it('sould prevent event default when dragover item', () => {
+ // arrange
+ // act
+ sort1.onItemDragover(event, 1);
+ // assert
+ expect(spyPreventDefault).toHaveBeenCalled();
+ });
+
+ it('souldn NOT prevent event default when no item is dragged over items', () => {
+ // arrange
+ spyGetItem.and.returnValue(undefined);
+ // act
+ sort1.onItemDragover(event, 1);
+ // assert
+ expect(spyPreventDefault).not.toHaveBeenCalled();
+ });
+
+ it('sould prevent event default when dragover zone', () => {
+ // arrange
+ // act
+ sort1.cancelEvent(event);
+ // assert
+ expect(spyPreventDefault).toHaveBeenCalled();
+ });
+
+ it('souldn NOT prevent event default when no item is dragged over zone', () => {
+ // arrange
+ spyGetItem.and.returnValue(undefined);
+ // act
+ sort1.cancelEvent(event);
+ // assert
+ expect(spyPreventDefault).not.toHaveBeenCalled();
+ });
+
+ it('should remove item if it was captured or dropped in another continer', () => {
+ // arrange
+ draggableItem.overZoneIndex = -1;
+ // act
+ sort1.onDrop(draggableItem);
+ // assert
+ expect(spyOnChanged).toHaveBeenCalledWith([ HEROES[1], HEROES[2], HEROES[3] ]);
+ });
+
+ it('shouldn NOT remove item if it was dropped in the same continer', () => {
+ // arrange
+ // act
+ sort1.onDrop(draggableItem);
+ // assert
+ expect(spyOnChanged).not.toHaveBeenCalled();
+ });
+
+ it('should fire onChanged when drag over item', () => {
+ // arrange
+ // act
+ sort1.onItemDragover(event, 1);
+ // assert
+ expect(spyOnChanged).toHaveBeenCalled();
+ });
+
+ it('should swap first and second item', () => {
+ // arrange
+ // act
+ sort1.onItemDragover(event, 1);
+ // assert
+ expect(spyOnChanged).toHaveBeenCalledWith([ HEROES[1], HEROES[0], HEROES[2], HEROES[3] ]);
+ });
+
+ it('should return unchanged array', () => {
+ // arrange
+ // act
+ sort1.onItemDragover(event, 0);
+ // assert
+ expect(spyOnChanged).toHaveBeenCalledWith(HEROES);
+ });
+
+ it('should move first item to the end', () => {
+ // arrange
+ // act
+ sort1.onItemDragover(event, 3);
+ // assert
+ expect(spyOnChanged).toHaveBeenCalledWith([ HEROES[1], HEROES[2], HEROES[3], HEROES[0] ]);
+ });
+
+ it('should move last item to the begining', () => {
+ // arrange
+ item.id = 3;
+ item.initData = item.value = HEROES[3];
+ draggableItem.i = 3;
+ // act
+ sort1.onItemDragover(event, 0);
+ // assert
+ expect(spyOnChanged).toHaveBeenCalledWith([ HEROES[3], HEROES[0], HEROES[1], HEROES[2] ]);
+ });
+
+ it('should insert a new item if was empty', () => {
+ // arrange
+ sort1.writeValue([]);
+ // act
+ sort1.onItemDragover(event, 0);
+ // assert
+ expect(spyOnChanged).toHaveBeenCalledWith([ HEROES[0] ]);
+ });
+
+ it('should insert a new item', () => {
+ // arrange
+ item.value = item.initData = 'new';
+ draggableItem.i = 4;
+ // act
+ sort1.onItemDragover(event, 0);
+ // assert
+ expect(spyOnChanged).toHaveBeenCalledWith([ 'new', ...HEROES ]);
+ });
+
+ it('should call onDrop when item is over an another container', fakeAsync(() => {
+ // arrange
+ spyGetItem.and.callThrough();
+ spyCaptureItem.and.callThrough();
+ sort1.onItemDragstart(event, item, 0);
+ // act
+ let capturedItem = transfer.captureItem(-1, 0);
+ // assert
+ transfer.onCaptureItem().subscribe(() => expect(spyOnDrop).toHaveBeenCalledWith(capturedItem));
+ }));
+
+ it('should remove item when it is over an another container', fakeAsync(() => {
+ // arrange
+ spyGetItem.and.callThrough();
+ spyCaptureItem.and.callThrough();
+ sort1.onItemDragstart(event, item, 0);
+ // act
+ let capturedItem = transfer.captureItem(-1, 0);
+ // assert
+ transfer.onCaptureItem().subscribe(() => expect(spyOnChanged).toHaveBeenCalledWith([ HEROES[1], HEROES[2], HEROES[3] ]));
+ }));
+
+ it('shouldn NOT remove item when it is dropped into the same container', fakeAsync(() => {
+ // arrange
+ spyGetItem.and.callThrough();
+ spyCaptureItem.and.callThrough();
+ sort1.onItemDragstart(event, item, 0);
+ // act
+ let capturedItem = transfer.captureItem(draggableItem.overZoneIndex, 4);
+ // assert
+ transfer.onCaptureItem().subscribe(() => expect(spyOnChanged).toHaveBeenCalledWith([ ...HEROES ]));
+ }));
+
+ it('should reset active item after drop', fakeAsync(() => {
+ // arrange
+ spyGetItem.and.callThrough();
+ spyCaptureItem.and.callThrough();
+ sort1.onItemDragstart(event, item, 0);
+ // act
+ let capturedItem = transfer.captureItem(draggableItem.overZoneIndex, 4);
+ // assert
+ transfer.onCaptureItem().subscribe(() => expect((sort1 as any).activeItem).toBe(-1));
+ }));
+
+ function getItemToDrag(): SortableItem {
+ return { id: 0, value: HEROES[0], initData: HEROES[0]};
+ }
+
+ function getDraggableItem(sortableItem: SortableItem, dragEvent: DragEvent, zone: number): DraggableItem {
+ return {
+ event: dragEvent,
+ item: sortableItem,
+ i: 0,
+ initialIndex: 0,
+ lastZoneIndex: zone,
+ overZoneIndex: zone
+ };
+ }
+ });
+
+ function getItemsByContainerId(id: string = 'sort1'): string[] {
+ return fixture.debugElement.queryAll(By.css(`#${id} div[draggable]`))
+ .map((item: any) => item.nativeElement.innerText);
+ }
+});