From fab3df506b94b4a4e9575d65249d5fa204903e2d Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 6 Jan 2017 16:48:05 +0100 Subject: [PATCH] feat(sortable): added new sortable component (#1295) * Added sortable-list module * added sortable-list module into the main module * renamed sortable-list to sortable * removed sortable module from root index.ts export everything from sortable module * inlined html template * created docs page for sortable component * removed console.log fixed lint warning added basic tests * added tests for reordering * added tests for onZoneDragover method * added test for insert item from another container * fixed quotes * fixed insertion test * finished component covering implemented tests for service * changed dirs structure fixed compatibility with ie11 * use dumb object instead of Event * updated docs * update docs (added doc comments) * fixed compatibility with firefox --- demo/src/app/app.module.ts | 2 + demo/src/app/app.routing.ts | 6 + .../app/components/sortable/demos/index.ts | 10 + .../demos/sortable-demo.component.html | 63 ++++ .../sortable/demos/sortable-demo.component.ts | 56 ++++ .../src/app/components/sortable/docs/title.md | 1 + .../src/app/components/sortable/docs/usage.md | 11 + demo/src/app/components/sortable/index.ts | 24 ++ .../sortable/sortable-section.component.html | 25 ++ .../sortable/sortable-section.component.ts | 19 ++ demo/src/ng-api-doc.ts | 87 +++++ src/index.ts | 1 + src/pagination/pager.component.ts | 2 +- src/pagination/pagination.component.ts | 2 +- src/sortable/draggable-item.service.ts | 37 +++ src/sortable/draggable-item.ts | 8 + src/sortable/index.ts | 4 + src/sortable/sortable.component.ts | 226 +++++++++++++ src/sortable/sortable.module.ts | 22 ++ src/spec/draggable-item.service.spec.ts | 80 +++++ src/spec/sortable.component.spec.ts | 298 ++++++++++++++++++ 21 files changed, 982 insertions(+), 2 deletions(-) create mode 100644 demo/src/app/components/sortable/demos/index.ts create mode 100644 demo/src/app/components/sortable/demos/sortable-demo.component.html create mode 100644 demo/src/app/components/sortable/demos/sortable-demo.component.ts create mode 100644 demo/src/app/components/sortable/docs/title.md create mode 100644 demo/src/app/components/sortable/docs/usage.md create mode 100644 demo/src/app/components/sortable/index.ts create mode 100644 demo/src/app/components/sortable/sortable-section.component.html create mode 100644 demo/src/app/components/sortable/sortable-section.component.ts create mode 100644 src/sortable/draggable-item.service.ts create mode 100644 src/sortable/draggable-item.ts create mode 100644 src/sortable/index.ts create mode 100644 src/sortable/sortable.component.ts create mode 100644 src/sortable/sortable.module.ts create mode 100644 src/spec/draggable-item.service.spec.ts create mode 100644 src/spec/sortable.component.spec.ts 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 69ac4d0175..2814c8b129 100644 --- a/demo/src/ng-api-doc.ts +++ b/demo/src/ng-api-doc.ts @@ -1463,6 +1463,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); + } +});