diff --git a/app/app.component.html b/app/app.component.html
index fb8dbbe..33ac567 100644
--- a/app/app.component.html
+++ b/app/app.component.html
@@ -1 +1,11 @@
-
Hooray
+Kanban board
+
diff --git a/app/app.component.ts b/app/app.component.ts
index 2156b7f..57f986e 100644
--- a/app/app.component.ts
+++ b/app/app.component.ts
@@ -1,8 +1,53 @@
import { Component } from '@angular/core';
+import { Lane } from './kanban/model';
+import { KanbanBoardService } from './kanban/kanban-board.service';
+import { Observable } from 'rxjs/Observable';
+
@Component({
- selector: 'my-app',
- templateUrl: `./app.component.html`
+ selector: 'my-app',
+ templateUrl: `./app.component.html`
})
-export class AppComponent {}
+export class AppComponent {
+ lanes$: Observable;
+
+ constructor(private kanbanBoardService: KanbanBoardService) {}
+
+ ngOnInit() {
+ this.lanes$ = this.kanbanBoardService.lanes;
+ this.kanbanBoardService.load();
+ }
+
+ onLaneMoved(event: { fromIndex: number, toIndex: number }) {
+ this.kanbanBoardService.moveLane(event.fromIndex, event.toIndex);
+ }
+
+ onLaneEdited(event: { id: string, name: string, editing: boolean }) {
+ this.kanbanBoardService.editLane(event);
+ }
+
+ onNoteCreated(id: string) {
+ this.kanbanBoardService.attachNote(id)
+ }
+
+ onNoteEdited(event: { laneId: string, id: string, text: string, editing: boolean }) {
+ this.kanbanBoardService.editNote(event);
+ }
+
+ onNoteMoved(event: { fromLaneId: string, fromIndex: number, toLaneId: string, toIndex?: number }) {
+ this.kanbanBoardService.moveNote(event);
+ }
+
+ onNoteDeleted(event: { laneId: string, id: string }) {
+ this.kanbanBoardService.deleteNote(event);
+ }
+
+ onNotesStatusChanged(event: { laneId: string, id: string, status: any}) {
+ this.kanbanBoardService.changeNoteStatus(event);
+ }
+
+ reset() {
+ this.kanbanBoardService.reset();
+ }
+}
diff --git a/app/app.module.ts b/app/app.module.ts
index ffe1c3f..f2bd7c1 100644
--- a/app/app.module.ts
+++ b/app/app.module.ts
@@ -2,14 +2,17 @@ import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
+import { KanbanModule } from './kanban/kanban.module';
+import { KanbanBoardService } from './kanban/kanban-board.service';
@NgModule({
imports: [
- BrowserModule
+ BrowserModule, KanbanModule
],
declarations: [
AppComponent
],
+ providers: [KanbanBoardService],
bootstrap: [ AppComponent ]
})
export class AppModule { }
diff --git a/app/kanban/dragula.directive.ts b/app/kanban/dragula.directive.ts
new file mode 100644
index 0000000..f97c51e
--- /dev/null
+++ b/app/kanban/dragula.directive.ts
@@ -0,0 +1,53 @@
+import { Directive, Input, ElementRef, OnInit, OnChanges, SimpleChange } from '@angular/core';
+import { DragulaService } from './dragula.service';
+import * as dragula from 'dragula';
+
+@Directive({selector: '[dragula]'})
+export class DragulaDirective implements OnInit, OnChanges {
+ @Input() public dragula: string;
+ @Input() public dragulaModel: any;
+ @Input() public dragulaOptions: any;
+ private container: any;
+ private drake: any;
+
+ public constructor(el: ElementRef, private dragulaService: DragulaService) {
+ this.dragulaService = dragulaService;
+ this.container = el.nativeElement;
+ }
+
+ public ngOnInit(): void {
+ let bag = this.dragulaService.find(this.dragula);
+ if (bag) {
+ this.drake = bag.drake;
+ this.checkModel();
+ this.drake.containers.push(this.container);
+ } else {
+ this.drake = dragula([this.container], Object.assign({}, this.dragulaOptions));
+ this.checkModel();
+ this.dragulaService.add(this.dragula, this.drake);
+ }
+ }
+
+ private checkModel() {
+ if (!this.dragulaModel) {
+ return;
+ }
+
+ if (this.drake.models) {
+ this.drake.models.push(this.dragulaModel);
+ } else {
+ this.drake.models = [this.dragulaModel];
+ }
+ }
+
+ public ngOnChanges(changes: {dragulaModel?: SimpleChange}): void {
+ if (this.drake && changes && changes.dragulaModel) {
+ if (this.drake.models) {
+ let modelIndex = this.drake.models.indexOf(changes.dragulaModel.previousValue);
+ this.drake.models.splice(modelIndex, 1, changes.dragulaModel.currentValue);
+ } else {
+ this.drake.models = [changes.dragulaModel.currentValue];
+ }
+ }
+ }
+}
diff --git a/app/kanban/dragula.service.ts b/app/kanban/dragula.service.ts
new file mode 100644
index 0000000..9a6801d
--- /dev/null
+++ b/app/kanban/dragula.service.ts
@@ -0,0 +1,109 @@
+import { Injectable, EventEmitter } from '@angular/core';
+
+import * as dragula from 'dragula';
+
+@Injectable()
+export class DragulaService {
+ public cancel: EventEmitter = new EventEmitter();
+ public cloned: EventEmitter = new EventEmitter();
+ public drag: EventEmitter = new EventEmitter();
+ public dragend: EventEmitter = new EventEmitter();
+ public drop: EventEmitter = new EventEmitter();
+ public out: EventEmitter = new EventEmitter();
+ public over: EventEmitter = new EventEmitter();
+ public remove: EventEmitter = new EventEmitter();
+ public shadow: EventEmitter = new EventEmitter();
+ public dropModel: EventEmitter = new EventEmitter();
+ private events: string[] = [
+ 'cancel', 'cloned', 'drag', 'dragend', 'drop', 'out', 'over',
+ 'remove', 'shadow', 'dropModel'
+ ];
+ private bags: any[] = [];
+
+ public add(name: string, drake: any): any {
+ let bag = this.find(name);
+ if (bag) {
+ throw new Error('Bag named: "' + name + '" already exists.');
+ }
+ bag = {name, drake};
+ this.bags.push(bag);
+ if (drake.models) { // models to sync with (must have same structure as containers)
+ this.handleModels(name, drake);
+ }
+ if (!bag.initEvents) {
+ this.setupEvents(bag);
+ }
+ return bag;
+ }
+
+ public find(name: string): any {
+ for (let bag of this.bags) {
+ if (bag.name === name) {
+ return bag;
+ }
+ }
+ }
+
+ public destroy(name: string): void {
+ let bag = this.find(name);
+ let i = this.bags.indexOf(bag);
+ this.bags.splice(i, 1);
+ bag.drake.destroy();
+ }
+
+ public setOptions(name: string, options: any): void {
+ let bag = this.add(name, dragula(options));
+ this.handleModels(name, bag.drake);
+ }
+
+ private handleModels(name: string, drake: any): void {
+ let dragElm: any;
+ let dragIndex: number;
+ let dropIndex: number;
+ let sourceModel: any;
+ drake.on('drag', (el: any, source: any) => {
+ dragElm = el;
+ dragIndex = this.domIndexOf(el, source);
+ });
+ drake.on('drop', (dropElm: any, target: any, source: any) => {
+ if (!drake.models || !target) {
+ return;
+ }
+ dropIndex = this.domIndexOf(dropElm, target);
+ sourceModel = drake.models[drake.containers.indexOf(source)];
+
+ if (target === source) {
+ sourceModel.splice(dropIndex, 0, sourceModel.splice(dragIndex, 1)[0]);
+ } else {
+ let notCopy = dragElm === dropElm;
+ let targetModel = drake.models[drake.containers.indexOf(target)];
+ let dropElmModel = notCopy ? sourceModel[dragIndex] : JSON.parse(JSON.stringify(sourceModel[dragIndex]));
+
+ if (notCopy) {
+ sourceModel.splice(dragIndex, 1);
+ }
+ targetModel.splice(dropIndex, 0, dropElmModel);
+ target.removeChild(dropElm); // element must be removed for ngFor to apply correctly
+ }
+ this.dropModel.emit([name, dropElm, target, source, dragIndex, dropIndex]);
+ });
+ }
+
+ private setupEvents(bag: any): void {
+ bag.initEvents = true;
+ let that: any = this;
+ let emitter = (type: any) => {
+ function replicate(): void {
+ let args = Array.prototype.slice.call(arguments);
+ that[type].emit([bag.name].concat(args));
+ }
+
+ bag.drake.on(type, replicate);
+ };
+ this.events.forEach(emitter);
+ }
+
+ private domIndexOf(child: any, parent: any): any {
+ return Array.prototype.indexOf.call(parent.children, child);
+ }
+}
diff --git a/app/kanban/editable.component.ts b/app/kanban/editable.component.ts
new file mode 100644
index 0000000..af6969d
--- /dev/null
+++ b/app/kanban/editable.component.ts
@@ -0,0 +1,49 @@
+import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
+
+@Component({
+ selector: 'editable',
+ template: `
+
+
+
+ {{value}}
+ ×
+
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class EditableComponent {
+ @Input() value: string;
+ @Input() editing: boolean;
+ @Input() onDelete: boolean;
+
+ @Output() valueClicked: EventEmitter = new EventEmitter();
+
+ @Output() edited: EventEmitter = new EventEmitter();
+
+ @ViewChild('autofocus') autofocus: ElementRef;
+
+ handleValueClick() {
+ this.valueClicked.emit();
+ }
+
+ handleFinishEdit(e) {
+ if ((e.type === 'keypress')) {
+ return;
+ }
+
+ const value = e.target.value;
+ this.edited.emit(value);
+ }
+
+ ngOnChanges() {
+ if (this.editing) {
+ setTimeout(() => this.autofocus.nativeElement.focus())
+ }
+ }
+
+}
diff --git a/app/kanban/kanban-board.service.ts b/app/kanban/kanban-board.service.ts
new file mode 100644
index 0000000..244686e
--- /dev/null
+++ b/app/kanban/kanban-board.service.ts
@@ -0,0 +1,292 @@
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs/Observable';
+import { BehaviorSubject } from 'rxjs/BehaviorSubject';
+import { UUID } from 'angular2-uuid';
+
+import { Lane, Note, NoteStatus } from './model';
+
+import * as localforage from 'localforage';
+
+
+const defaultState: Lane[] = [
+ {
+ id: UUID.UUID(),
+ name: 'Todo',
+ editing: false,
+ notes: [
+ {
+ id: UUID.UUID(),
+ status: NoteStatus.InProgress,
+ text: 'Navbar and Office Navigation Bar Enhancements',
+ editing: false
+ },
+ {
+ id: UUID.UUID(),
+ status: NoteStatus.Overdue,
+ text: 'PictureEdit - Trim Image by Mask/Shape',
+ editing: false
+ },
+ {
+ id: UUID.UUID(),
+ status: NoteStatus.InProgress,
+ text: 'Calendar Control - Touch Mode',
+ editing: false
+ }
+ ]
+ },
+ {
+ id: UUID.UUID(),
+ name: 'In Progress',
+ editing: false,
+ notes: [
+ {
+ id: UUID.UUID(),
+ status: NoteStatus.InProgress,
+ text: 'Stub Glyphs for BarItems in Ribbon & BarManager',
+ editing: false
+ }
+ ]
+ },
+ {
+ id: UUID.UUID(),
+ name: 'Review',
+ editing: false,
+ notes: []
+ },
+ {
+ id: UUID.UUID(),
+ name: 'Done',
+ editing: false,
+ notes: [
+ {
+ id: UUID.UUID(),
+ status: NoteStatus.Completed,
+ text: 'MVVM Core Enhancements',
+ editing: false
+ },
+ {
+ id: UUID.UUID(),
+ status: NoteStatus.InProgress,
+ text: 'Filtering UI Enhancements',
+ editing: false
+ },
+ {
+ id: UUID.UUID(),
+ status: NoteStatus.InProgress,
+ text: 'Master Detail Mode - Single Vertical Scrollbar for Multiple Views',
+ editing: false
+ }
+ ]
+ }
+];
+
+const storage: LocalForage = localforage.createInstance({
+ name: 'kanban',
+});
+
+
+@Injectable()
+export class KanbanBoardService {
+ private _lanes: BehaviorSubject = new BehaviorSubject([]);
+ private dataStore: { lanes: Lane[] } = {lanes: []};
+
+ get lanes(): Observable {
+ return this._lanes.asObservable();
+ }
+
+ load() {
+ storage.getItem('state').then((data: Lane[]) => {
+ this.dataStore.lanes = data || defaultState;
+ this.dispatch();
+ });
+ this.lanes.subscribe((state) => storage.setItem('state', state));
+ }
+
+ moveLane(fromIndex: number, toIndex: number) {
+ let newArr = [...this.dataStore.lanes];
+ /* let movedLane = newArr[fromIndex];
+ newArr.splice(fromIndex, 1);
+ newArr.splice(toIndex, 0, movedLane);
+ */
+ this.dataStore.lanes = newArr;
+ this.dispatch();
+ }
+
+ editLane(action: EditLaneAction) {
+ this.dataStore.lanes = this.dataStore.lanes.map((lane: Lane) => {
+ if (lane.id !== action.id) {
+ return lane;
+ }
+
+ return Object.assign({}, lane, {
+ name: action.name,
+ editing: action.editing
+ });
+ });
+ this.dispatch();
+ }
+
+ attachNote(id: string) {
+ this.dataStore.lanes = this.dataStore.lanes.map((lane: Lane) => {
+ if (lane.id !== id) {
+ return lane;
+ }
+
+ const note: Note = {
+ id: UUID.UUID(),
+ editing: true,
+ text: '',
+ status: NoteStatus.InProgress
+ };
+
+ return Object.assign({}, lane, {notes: [...lane.notes, note]});
+ });
+
+ this.dispatch();
+ }
+
+ editNote(action: EditNoteAction) {
+ this.dataStore.lanes = this.dataStore.lanes.map((lane: Lane) => {
+ if (lane.id !== action.laneId) {
+ return lane;
+ }
+
+ lane.notes = lane.notes.map(note => {
+ if (note.id !== action.id) {
+ return note;
+ }
+
+ return Object.assign({}, note, {
+ text: action.text,
+ editing: action.editing
+ });
+ });
+ return lane;
+ });
+
+ this.dispatch();
+ }
+
+ moveNote(action: MoveNoteAction) {
+ if (action.fromLaneId === action.toLaneId) {
+ this.dataStore.lanes = this.dataStore.lanes.map((lane: Lane) => {
+ if (lane.id !== action.fromLaneId) {
+ return lane;
+ }
+
+ let newArr = [...lane.notes];
+ /* let movedNote = newArr[action.fromIndex];
+ newArr.splice(action.fromIndex, 1);
+ newArr.splice(action.toIndex, 0, movedNote);*/
+
+ return Object.assign({}, lane, {notes: newArr});
+ });
+ } else {
+ let movedNote;
+ this.dataStore.lanes = this.dataStore.lanes.map((lane: Lane) => {
+ if (lane.id === action.fromLaneId) {
+ let newArr = [...lane.notes];
+ // movedNote = newArr.splice(action.fromIndex, 1)[0];
+
+ return Object.assign({}, lane, {notes: newArr});
+ }
+
+ return lane;
+ });
+ this.dataStore.lanes = this.dataStore.lanes.map((lane: Lane) => {
+ if (lane.id === action.toLaneId) {
+ let newArr = [...lane.notes];
+ /* if(action.toIndex === null) {
+ newArr.push(movedNote);
+ } else {
+ newArr.splice(action.toIndex, 0, movedNote);
+ }*/
+
+ return Object.assign({}, lane, {notes: newArr});
+ }
+
+ return lane;
+ });
+ }
+
+ this.dispatch();
+ }
+
+ deleteNote(action: DeleteNoteAction) {
+ this.dataStore.lanes = this.dataStore.lanes.map((lane: Lane) => {
+ if (lane.id !== action.laneId) {
+ return lane;
+ }
+ let newArr = lane.notes.filter(x => x.id !== action.id);
+
+ return Object.assign({}, lane, {notes: newArr});
+ });
+
+ this.dispatch();
+ }
+
+ changeNoteStatus(action: ChangeNoteStatusAction) {
+ this.dataStore.lanes = this.dataStore.lanes.map((lane: Lane) => {
+ if (lane.id !== action.laneId) {
+ return lane;
+ }
+
+ lane.notes = lane.notes.map(note => {
+ if (note.id !== action.id) {
+ return note;
+ }
+
+ return Object.assign({}, note, {
+ status: +action.status
+ });
+ });
+ return lane;
+ });
+
+ this.dispatch();
+ }
+
+ dispatch() {
+ this._lanes.next(Object.assign({}, this.dataStore).lanes);
+ }
+
+ reset() {
+ storage.clear();
+ window.location.reload();
+ }
+}
+
+
+export interface MoveLaneAction {
+ fromIndex: number;
+ toIndex: number;
+}
+
+export interface MoveNoteAction {
+ fromLaneId: string;
+ fromIndex: number;
+ toLaneId: string;
+ toIndex?: number
+}
+
+export interface EditLaneAction {
+ id: string;
+ name: string;
+ editing: boolean;
+}
+
+export interface EditNoteAction {
+ laneId: string;
+ id: string;
+ text: string;
+ editing: boolean;
+}
+
+export interface DeleteNoteAction {
+ laneId: string;
+ id: string;
+}
+
+export interface ChangeNoteStatusAction extends DeleteNoteAction {
+ status: any;
+}
\ No newline at end of file
diff --git a/app/kanban/kanban.component.ts b/app/kanban/kanban.component.ts
new file mode 100644
index 0000000..ea62186
--- /dev/null
+++ b/app/kanban/kanban.component.ts
@@ -0,0 +1,142 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnDestroy, Output } from '@angular/core';
+import { Lane } from './model';
+import 'rxjs/add/operator/first';
+import { DragulaService } from './dragula.service';
+
+@Component({
+ selector: 'kanban',
+ template: `
+
+
+
+
+ `,
+ host: { class: 'kanban' },
+ providers: [DragulaService],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class KanbanComponent implements OnDestroy {
+ @Input() lanes: Lane[];
+
+ @Output() laneMoved: EventEmitter<{ fromIndex: number, toIndex: number }> = new EventEmitter();
+ @Output() laneEdited: EventEmitter<{ id: string, name: string, editing: boolean }> = new EventEmitter();
+
+ @Output() createNote: EventEmitter = new EventEmitter();
+ @Output() noteEdited: EventEmitter<{ laneId: string, id: string, text: string, editing: boolean }> = new EventEmitter();
+ @Output() noteMoved: EventEmitter<{ fromLaneId: string, fromIndex: number, toLaneId: string, toIndex?: number }> = new EventEmitter();
+ @Output() noteDeleted: EventEmitter<{ laneId: string, id: string }> = new EventEmitter();
+ @Output() noteStatusChanged: EventEmitter = new EventEmitter();
+
+ drakeName = 'lanesContainer';
+
+ dragulaOptions = {
+ moves: (el, container, handle) => {
+ return handle.classList.contains('lane__drag');
+ }
+ };
+
+ trackById(i: number, item: Lane) {
+ return item.id;
+ }
+
+ constructor(private dragulaService: DragulaService) {
+ this.dragulaService.dropModel.subscribe(([bagName, el, target, source, dragIndex, dropIndex]) => {
+ if(bagName === this.drakeName) {
+ this.laneMoved.emit({ fromIndex: dragIndex, toIndex: dropIndex })
+ }
+ if(bagName === 'notesContainer') {
+ this.noteMoved.emit({ fromLaneId: source.dataset.key, fromIndex: dragIndex, toLaneId: target.dataset.key, toIndex: dropIndex })
+ }
+ });
+ }
+
+ ngAfterViewInit() {
+ /* let drake = dragula([this.lanesContainer.nativeElement], {
+ moves: (el, container, handle) => {
+ return handle.classList.contains('lane__drag');
+ }
+ });
+ drake.on('drop', (el, target, source, sibling) => {
+ const fromIndex = el.dataset.index;
+ let toIndex;
+ if (sibling) {
+ if (fromIndex < sibling.dataset.index) {
+ toIndex = sibling.dataset.index - 1;
+ } else {
+ toIndex = sibling.dataset.index;
+ }
+ } else {
+ toIndex = this.lanes.length - 1;
+ }
+ console.log('from ' + fromIndex + ' to ' + toIndex);
+
+ this.laneMoved.emit({ fromIndex, toIndex})
+ });*/
+
+ /* this.drakeNotes = dragula();
+ this.drakeNotes.on('drop', (el, target, source, sibling) => {
+ if(!target) return;
+ const fromIndex = el.dataset.index;
+ let toIndex;
+
+ const fromLaneId = source.dataset.key;
+ const toLaneId = target.dataset.key;
+
+ if(fromLaneId === toLaneId) {
+ if (sibling) {
+ if (fromIndex < sibling.dataset.index) {
+ toIndex = sibling.dataset.index - 1;
+ } else {
+ toIndex = sibling.dataset.index;
+ }
+ } else {
+ toIndex = this.lanes.length - 1;
+ }
+ } else {
+ toIndex = sibling ? sibling.dataset.index : null;
+ }
+
+ // el.parentNode.removeChild(el);
+ this.noteMoved.emit({ fromLaneId, fromIndex, toLaneId, toIndex })
+ });*/
+ }
+
+ onEditLane(id: string, currentName: string, newName: string) {
+ const name = newName !== undefined ? newName : currentName;
+ const editing = newName === undefined;
+
+ this.laneEdited.emit({ id, name, editing })
+ }
+
+ onCreateNote(id: string) {
+ this.createNote.emit(id);
+ }
+
+ onEditNote(laneId: string, event: { id: string, currentText: string, text: string }) {
+ const text = event.text !== undefined ? event.text : event.currentText;
+ const editing = event.text === undefined;
+
+ this.noteEdited.emit({ laneId, id: event.id, text, editing });
+ }
+
+ onDeleteNote(laneId: string, event: any) {
+ this.noteDeleted.emit({laneId, id: event});
+ }
+
+ onNoteStatusChanged(laneId: string, { id, status }) {
+ this.noteStatusChanged.emit({ laneId, id, status });
+ }
+
+ ngOnDestroy() {
+ this.dragulaService.destroy(this.drakeName);
+ this.dragulaService.destroy('notesContainer');
+ }
+}
diff --git a/app/kanban/kanban.module.ts b/app/kanban/kanban.module.ts
new file mode 100644
index 0000000..fa91925
--- /dev/null
+++ b/app/kanban/kanban.module.ts
@@ -0,0 +1,24 @@
+import { NgModule } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+import { KanbanComponent } from './kanban.component';
+import { LaneComponent } from './lane.component';
+import { EditableComponent } from './editable.component';
+import { NotesComponent } from './notes.component';
+import { DragulaDirective } from './dragula.directive';
+
+@NgModule({
+ imports: [
+ CommonModule
+ ],
+ declarations: [
+ KanbanComponent,
+ LaneComponent,
+ EditableComponent,
+ NotesComponent,
+ DragulaDirective
+ ],
+ exports: [KanbanComponent]
+})
+export class KanbanModule {
+}
diff --git a/app/kanban/lane.component.ts b/app/kanban/lane.component.ts
new file mode 100644
index 0000000..fab239b
--- /dev/null
+++ b/app/kanban/lane.component.ts
@@ -0,0 +1,41 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
+
+@Component({
+ selector: 'lane',
+ template: `
+
+
{{ lane.name }}
+
+
+
+ `,
+ host: { class: 'lane' },
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class LaneComponent {
+ @Input() lane: any;
+
+ @Output() createNote: EventEmitter = new EventEmitter();
+ @Output() editLane: EventEmitter = new EventEmitter();
+ @Output() editNote: EventEmitter = new EventEmitter();
+ @Output() deleteNote: EventEmitter = new EventEmitter();
+ @Output() noteStatusChanged: EventEmitter = new EventEmitter();
+
+ onEditLane(name: string) {
+ this.editLane.emit(name);
+ }
+
+ onEditNote(event: any) {
+ this.editNote.emit(event)
+ }
+
+ onDeleteNote(event: any) {
+ this.deleteNote.emit(event);
+ }
+}
diff --git a/app/kanban/model.ts b/app/kanban/model.ts
new file mode 100644
index 0000000..a75fb70
--- /dev/null
+++ b/app/kanban/model.ts
@@ -0,0 +1,21 @@
+
+
+export enum NoteStatus {
+ InProgress,
+ Overdue,
+ Completed
+}
+
+export class Note {
+ id: string;
+ editing: boolean;
+ text: string;
+ status: NoteStatus
+}
+
+export interface Lane {
+ id: string;
+ name: string;
+ editing: boolean;
+ notes: Note[];
+}
diff --git a/app/kanban/notes.component.ts b/app/kanban/notes.component.ts
new file mode 100644
index 0000000..9cb91eb
--- /dev/null
+++ b/app/kanban/notes.component.ts
@@ -0,0 +1,56 @@
+import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
+import { Note } from './model';
+
+@Component({
+ selector: 'notes',
+ template: `
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class NotesComponent {
+ @Input() laneId: string;
+ @Input() notes: Note[];
+
+ drakeName = 'notesContainer';
+
+ dragulaOptions = {
+ moves: (el, container, handle) => {
+ return handle.classList.contains('note__drag');
+ }
+ };
+
+ @Output() editNote: EventEmitter = new EventEmitter();
+ @Output() noteStatusChanged: EventEmitter = new EventEmitter();
+ @Output() deleteNote: EventEmitter = new EventEmitter();
+
+ @ViewChild('notesContainer') notesContainer: ElementRef;
+
+ onEditNote(id: string, currentText: string, newText: string) {
+ this.editNote.emit({ id, currentText, text: newText })
+ }
+
+ handleDelete(id: string) {
+ this.deleteNote.emit(id);
+ }
+}
diff --git a/images/drag-cursor.png b/images/drag-cursor.png
new file mode 100644
index 0000000..d7a86de
Binary files /dev/null and b/images/drag-cursor.png differ
diff --git a/images/drag-pattern.png b/images/drag-pattern.png
new file mode 100644
index 0000000..f08fded
Binary files /dev/null and b/images/drag-pattern.png differ
diff --git a/package.json b/package.json
index 23f470a..7a32047 100644
--- a/package.json
+++ b/package.json
@@ -18,9 +18,13 @@
"@angular/platform-browser": "4.2.5",
"@angular/platform-browser-dynamic": "4.2.5",
"@angular/router": "4.2.5",
+ "angular2-uuid": "^1.1.1",
"core-js": "^2.4.1",
+ "dragula": "^3.7.2",
+ "localforage": "^1.5.0",
"rxjs": "^5.1.0",
"systemjs": "0.19.41",
+ "uuid": "^3.1.0",
"zone.js": "^0.8.11"
},
"devDependencies": {
diff --git a/style.css b/style.css
index e69de29..46b16cb 100644
--- a/style.css
+++ b/style.css
@@ -0,0 +1,708 @@
+/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */
+/**
+ * 1. Set default font family to sans-serif.
+ * 2. Prevent iOS and IE text size adjust after device orientation change,
+ * without disabling user zoom.
+ */
+html {
+ font-family: sans-serif;
+ /* 1 */
+ -ms-text-size-adjust: 100%;
+ /* 2 */
+ -webkit-text-size-adjust: 100%;
+ /* 2 */
+}
+
+/**
+ * Remove default margin.
+ */
+body {
+ margin: 0;
+}
+
+/* HTML5 display definitions
+ ========================================================================== */
+/**
+ * Correct `block` display not defined for any HTML5 element in IE 8/9.
+ * Correct `block` display not defined for `details` or `summary` in IE 10/11
+ * and Firefox.
+ * Correct `block` display not defined for `main` in IE 11.
+ */
+article,
+aside,
+details,
+figcaption,
+figure,
+footer,
+header,
+hgroup,
+main,
+menu,
+nav,
+section,
+summary {
+ display: block;
+}
+
+/**
+ * 1. Correct `inline-block` display not defined in IE 8/9.
+ * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera.
+ */
+audio,
+canvas,
+progress,
+video {
+ display: inline-block;
+ /* 1 */
+ vertical-align: baseline;
+ /* 2 */
+}
+
+/**
+ * Prevent modern browsers from displaying `audio` without controls.
+ * Remove excess height in iOS 5 devices.
+ */
+audio:not([controls]) {
+ display: none;
+ height: 0;
+}
+
+/**
+ * Address `[hidden]` styling not present in IE 8/9/10.
+ * Hide the `template` element in IE 8/9/10/11, Safari, and Firefox < 22.
+ */
+[hidden],
+template {
+ display: none;
+}
+
+/* Links
+ ========================================================================== */
+/**
+ * Remove the gray background color from active links in IE 10.
+ */
+a {
+ background-color: transparent;
+}
+
+/**
+ * Improve readability of focused elements when they are also in an
+ * active/hover state.
+ */
+a:active,
+a:hover {
+ outline: 0;
+}
+
+/* Text-level semantics
+ ========================================================================== */
+/**
+ * Address styling not present in IE 8/9/10/11, Safari, and Chrome.
+ */
+abbr[title] {
+ border-bottom: 1px dotted;
+}
+
+/**
+ * Address style set to `bolder` in Firefox 4+, Safari, and Chrome.
+ */
+b,
+strong {
+ font-weight: bold;
+}
+
+/**
+ * Address styling not present in Safari and Chrome.
+ */
+dfn {
+ font-style: italic;
+}
+
+/**
+ * Address variable `h1` font-size and margin within `section` and `article`
+ * contexts in Firefox 4+, Safari, and Chrome.
+ */
+h1 {
+ font-size: 2em;
+ margin: 0.67em 0;
+}
+
+/**
+ * Address styling not present in IE 8/9.
+ */
+mark {
+ background: #ff0;
+ color: #000;
+}
+
+/**
+ * Address inconsistent and variable font size in all browsers.
+ */
+small {
+ font-size: 80%;
+}
+
+/**
+ * Prevent `sub` and `sup` affecting `line-height` in all browsers.
+ */
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sup {
+ top: -0.5em;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+/* Embedded content
+ ========================================================================== */
+/**
+ * Remove border when inside `a` element in IE 8/9/10.
+ */
+img {
+ border: 0;
+}
+
+/**
+ * Correct overflow not hidden in IE 9/10/11.
+ */
+svg:not(:root) {
+ overflow: hidden;
+}
+
+/* Grouping content
+ ========================================================================== */
+/**
+ * Address margin not present in IE 8/9 and Safari.
+ */
+figure {
+ margin: 1em 40px;
+}
+
+/**
+ * Address differences between Firefox and other browsers.
+ */
+hr {
+ box-sizing: content-box;
+ height: 0;
+}
+
+/**
+ * Contain overflow in all browsers.
+ */
+pre {
+ overflow: auto;
+}
+
+/**
+ * Address odd `em`-unit font size rendering in all browsers.
+ */
+code,
+kbd,
+pre,
+samp {
+ font-family: monospace, monospace;
+ font-size: 1em;
+}
+
+/* Forms
+ ========================================================================== */
+/**
+ * Known limitation: by default, Chrome and Safari on OS X allow very limited
+ * styling of `select`, unless a `border` property is set.
+ */
+/**
+ * 1. Correct color not being inherited.
+ * Known issue: affects color of disabled elements.
+ * 2. Correct font properties not being inherited.
+ * 3. Address margins set differently in Firefox 4+, Safari, and Chrome.
+ */
+button,
+input,
+optgroup,
+select,
+textarea {
+ color: inherit;
+ /* 1 */
+ font: inherit;
+ /* 2 */
+ margin: 0;
+ /* 3 */
+}
+
+textarea {
+ resize: vertical;
+}
+/**
+ * Address `overflow` set to `hidden` in IE 8/9/10/11.
+ */
+button {
+ overflow: visible;
+}
+
+/**
+ * Address inconsistent `text-transform` inheritance for `button` and `select`.
+ * All other form control elements do not inherit `text-transform` values.
+ * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.
+ * Correct `select` style inheritance in Firefox.
+ */
+button,
+select {
+ text-transform: none;
+}
+
+/**
+ * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
+ * and `video` controls.
+ * 2. Correct inability to style clickable `input` types in iOS.
+ * 3. Improve usability and consistency of cursor style between image-type
+ * `input` and others.
+ */
+button,
+html input[type="button"],
+input[type="reset"],
+input[type="submit"] {
+ -webkit-appearance: button;
+ /* 2 */
+ cursor: pointer;
+ /* 3 */
+}
+
+/**
+ * Re-set default cursor for disabled elements.
+ */
+button[disabled],
+html input[disabled] {
+ cursor: default;
+}
+
+/**
+ * Remove inner padding and border in Firefox 4+.
+ */
+button::-moz-focus-inner,
+input::-moz-focus-inner {
+ border: 0;
+ padding: 0;
+}
+
+/**
+ * Address Firefox 4+ setting `line-height` on `input` using `!important` in
+ * the UA stylesheet.
+ */
+input {
+ line-height: normal;
+}
+
+/**
+ * It's recommended that you don't attempt to style these elements.
+ * Firefox's implementation doesn't respect box-sizing, padding, or width.
+ *
+ * 1. Address box sizing set to `content-box` in IE 8/9/10.
+ * 2. Remove excess padding in IE 8/9/10.
+ */
+input[type="checkbox"],
+input[type="radio"] {
+ box-sizing: border-box;
+ /* 1 */
+ padding: 0;
+ /* 2 */
+}
+
+/**
+ * Fix the cursor style for Chrome's increment/decrement buttons. For certain
+ * `font-size` values of the `input`, it causes the cursor style of the
+ * decrement button to change from `default` to `text`.
+ */
+input[type="number"]::-webkit-inner-spin-button,
+input[type="number"]::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/**
+ * 1. Address `appearance` set to `searchfield` in Safari and Chrome.
+ * 2. Address `box-sizing` set to `border-box` in Safari and Chrome.
+ */
+input[type="search"] {
+ -webkit-appearance: textfield;
+ /* 1 */
+ box-sizing: content-box;
+ /* 2 */
+}
+
+/**
+ * Remove inner padding and search cancel button in Safari and Chrome on OS X.
+ * Safari (but not Chrome) clips the cancel button when the search input has
+ * padding (and `textfield` appearance).
+ */
+input[type="search"]::-webkit-search-cancel-button,
+input[type="search"]::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/**
+ * Define consistent border, margin, and padding.
+ */
+fieldset {
+ border: 1px solid #c0c0c0;
+ margin: 0 2px;
+ padding: 0.35em 0.625em 0.75em;
+}
+
+/**
+ * 1. Correct `color` not being inherited in IE 8/9/10/11.
+ * 2. Remove padding so people aren't caught out if they zero out fieldsets.
+ */
+legend {
+ border: 0;
+ /* 1 */
+ padding: 0;
+ /* 2 */
+}
+
+/**
+ * Remove default vertical scrollbar in IE 8/9/10/11.
+ */
+textarea {
+ overflow: auto;
+}
+
+/**
+ * Don't inherit the `font-weight` (applied by a rule above).
+ * NOTE: the default cannot safely be changed in Chrome and Safari on OS X.
+ */
+optgroup {
+ font-weight: bold;
+}
+
+/* Tables
+ ========================================================================== */
+/**
+ * Remove most spacing between table cells.
+ */
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+}
+
+td,
+th {
+ padding: 0;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ background-attachment: fixed;
+ background-color: #e8e8e8;
+ color: #5a5050;
+ font-family: 'Source Sans Pro', sans-serif;
+ font-size: 16px;
+ font-weight: 400;
+ height: 100%;
+}
+
+html {
+ height: 100%;
+}
+
+a {
+ color: #a6206a;
+ text-decoration: none;
+ transition: color ease-in-out .4s;
+}
+
+a:hover {
+ color: #7b184f;
+}
+
+p {
+ font-size: 1em;
+ font-weight: 300;
+}
+
+h1,
+h2 {
+ margin: 0;
+}
+
+h2 {
+ font-size: 1.2em;
+}
+
+img {
+ max-width: 100%;
+}
+
+button {
+ background-color: #727272;
+ border: 0;
+ border-radius: 2px;
+ box-shadow: 0 2px 3px rgba(0, 0, 0, 0.2);
+ color: #fff;
+ padding: .5em 1em;
+ transition: all .3s ease-out;
+}
+
+button:focus {
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
+ outline: 0;
+}
+
+input,
+.input,
+textarea {
+ border: 0;
+ font-weight: normal;
+ width: 100%;
+ padding: .6em 0.3em;
+ background: transparent;
+}
+
+textarea:focus,
+input:focus {
+ outline: none;
+}
+
+.kanban {
+ flex: 1 1 auto;
+ margin: 0 auto;
+ width: 95%;
+ display: flex;
+ position: relative;
+
+}
+
+my-app {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.kanban-title {
+ font-weight: 400;
+ margin-top: 20px;
+ text-align: center;
+}
+
+.add-lane {
+ background-color: #467075;
+ margin-bottom: .4em;
+}
+
+.add-lane:hover, .add-lane:focus {
+ background-color: #467075;
+}
+
+.reset-store {
+ background-color: rgba(0, 188, 212, 0);
+ box-shadow: none;
+ color: #F44336;
+ font-weight: 600;
+ font-size: 15px;
+}
+
+.reset-store:hover, .reset-store:focus {
+ text-decoration: underline;
+}
+
+.lanes {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap;
+ -ms-flex-pack: center;
+ justify-content: center;
+ margin: 0 -15px;
+ flex: 1 1 auto;
+}
+
+
+.lane {
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ margin-top: 1.5em;
+ position: relative;
+ padding: 0 15px;
+ display: flex;
+ flex-direction: column;
+ width: 25%;
+}
+
+
+
+.lane__name {
+ padding: 0 .5em;
+ margin-bottom: 5px;
+}
+
+.lane__editable {
+ font-weight: bold;
+}
+.lane__name .editing {
+ box-shadow: inset 0 -2px #f7d969;
+}
+
+.lane__delete {
+ background-color: #fff;
+ border: 1px solid #f3f3f4;
+ box-shadow: none;
+ color: #5a5050;
+ height: 1em;
+ line-height: 1em;
+ padding: 0;
+ position: absolute;
+ right: .5em;
+ top: -.5em;
+ width: 1em;
+}
+
+.lane__drag {
+ background-color: transparent;
+ background-image: url("images/drag-cursor.png");
+ background-position: center;
+ cursor: move;
+ height: 1em;
+ position: absolute;
+ right: 20px;
+ top: 10px;
+ width: 1em;
+}
+
+.lane__drag:focus {
+ box-shadow: none;
+}
+
+
+
+.add-note {
+ background-color: #4caf50;
+ line-height: 28px;
+ padding: 0 10px;
+ display: inline-block;
+ font-size: 15px;
+}
+
+
+
+.notes-list {
+ list-style-type: none;
+ padding: 0;
+ display: block;
+ min-height: 30px;
+}
+
+
+notes {
+ flex: 1 1 auto;
+ overflow-y: auto;
+ padding: 0 5px;
+}
+.note {
+ background-color: #fff;
+ border-left: 5px solid #f06562;
+ margin: .725em 0;
+ padding: 15px 1.6em;
+ position: relative;
+ list-style: none;
+}
+
+.note__drag {
+ position: absolute;
+ background: url("images/drag-pattern.png") no-repeat;
+ left: 5px;
+ top: 50%;
+ width: 13px;
+ height: 23px;
+ cursor: move;
+ margin-top: -11.5px;
+}
+
+.note-status {
+ margin-top: 10px;
+ text-align: right;
+}
+
+.note--0 {
+ border-left-color: #e6c730;
+}
+
+.note--1 {
+ border-left-color: #f06562;
+}
+
+.note--2 {
+ border-left-color: #96c244;
+}
+
+.note input,
+.note textarea,
+.note .input{
+ border-bottom: 2px solid transparent;
+ cursor: text;
+ color: #4070d6;
+ font-weight: bold;
+}
+
+
+.note .editing {
+ border-bottom: 2px solid #ccc;
+}
+
+.note .delete {
+ color: rgba(90, 80, 80, 0.3);
+ cursor: pointer;
+ font-size: 2rem;
+ font-weight: 600;
+ margin-left: 5px;
+ position: absolute;
+ right: 15px;
+ top: 1px;
+ vertical-align: sub;
+}
+
+
+.gu-mirror {
+ position: fixed !important;
+ margin: 0 !important;
+ z-index: 9999 !important;
+ opacity: 0.8;
+ -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=80)";
+ filter: alpha(opacity=80);
+}
+.gu-hide {
+ display: none !important;
+}
+.gu-unselectable {
+ -webkit-user-select: none !important;
+ -moz-user-select: none !important;
+ -ms-user-select: none !important;
+ user-select: none !important;
+}
+.gu-transit {
+ opacity: 0.2;
+ -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=20)";
+ filter: alpha(opacity=20);
+}
+
+::-webkit-scrollbar {
+ width: 10px;
+}
+
+::-webkit-scrollbar-track {
+ /* -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3);
+ border-radius: 10px;*/
+}
+
+::-webkit-scrollbar-thumb {
+ border-radius: 10px;
+ background: #c6c6c6;
+}
\ No newline at end of file
diff --git a/systemjs.config.js b/systemjs.config.js
index a80dc6c..cb9dbbf 100644
--- a/systemjs.config.js
+++ b/systemjs.config.js
@@ -27,6 +27,9 @@
'@angular/platform-browser/animations': 'npm:@angular/platform-browser/bundles/platform-browser-animations.umd.js',
'@angular/material': 'npm:@angular/material/bundles/material.umd.js',
+ 'dragula': 'npm:dragula/dist/dragula.js',
+ 'localforage': 'npm:localforage/dist/localforage.js',
+ 'angular2-uuid': 'npm:angular2-uuid/index.js',
// other libraries
'rxjs': 'npm:rxjs'
},