DEV Community

John Au-Yeung
John Au-Yeung

Posted on

How To Build an App With Drag and Drop With Angular

Subscribe to my email list now at http://jauyeung.net/subscribe/

Follow me on Twitter at https://twitter.com/AuMayeung

Many more articles at https://medium.com/@hohanga

Drag and drop is a feature of many interactive web apps. It provides an intuitive way for users to manipulate their data. Adding a drag and drop feature is easy for Angular apps.

In this piece, we will create a to-do app that has two columns: a to-do column and a done column. You can drag and drop between the two to change the status from to-do to done and vice versa.

To build the app, we use the Angular Material library to make the app look good and to easily provide drag and drop abilities to our app. It will also have a navigation menu and a top bar.

To start building the app, we install the Angular CLI by running npm i @angular/cli. We should choose to include routing and use SCSS when asked.

Then, we create a new Angular project by running ng new todo-app. After that, we add the libraries we need, by running npm i@angular/cdk @angular/material @ngrx/store.

This will add Angular Material and the NGRX store to our app. We will use Flux extensively with this app. Next, we add our components and services by running the following:

ng g component addTodoDialog  
ng g component homePage  
ng g component toolBar  
ng g service todo
Enter fullscreen mode Exit fullscreen mode

We add the boilerplate for the NGRX store by running ng add @ngrx/store.

Now, we can build the logic for our app. In add-todo-dialog.component.ts, we add:

import { Component, OnInit } from '@angular/core';  
import { NgForm } from '@angular/forms';  
import { MatDialogRef } from '@angular/material/dialog';  
import { Store } from '@ngrx/store';  
import { TodoService } from '../todo.service';  
import { SET_TODOS } from '../reducers/todo-reducer';

@Component({  
  selector: 'app-add-todo-dialog',  
  templateUrl: './add-todo-dialog.component.html',  
  styleUrls: ['./add-todo-dialog.component.scss']  
})  
export class AddTodoDialogComponent implements OnInit {  
  todoData: any = <any>{  
    done: false  
  }; 

  constructor(  
    public dialogRef: MatDialogRef<AddTodoDialogComponent>,  
    private todoService: TodoService,  
    private store: Store<any>  
  ) { } 

  ngOnInit() {  
  } 

  save(todoForm: NgForm) {  
    if (todoForm.invalid) {  
      return;  
    }  
    this.todoService.addTodo(this.todoData)  
      .subscribe(res => {  
        this.getTodos();  
        this.dialogRef.close();  
      })  
  } 

  getTodos() {  
    this.todoService.getTodos()  
      .subscribe(res => {  
        this.store.dispatch({ type: SET_TODOS, payload: res });  
      })  
  }  
}
Enter fullscreen mode Exit fullscreen mode

This code is for the dialog box that lets us add the to-do items to a list, then get the latest items and put them in the store.

In add-todo-dialog.component.html, we add:

<h2>Add Todo</h2>  
<form #todoForm='ngForm' (ngSubmit)='save(todoForm)'>  
    <mat-form-field>  
        <input matInput placeholder="Description" required #description='ngModel' name='description'  
            [(ngModel)]='todoData.description'>  
        <mat-error *ngIf="description.invalid && (description.dirty || description.touched)">  
            <div *ngIf="content.errors.required">  
                Description is required.  
            </div>  
        </mat-error>  
    </mat-form-field>  
    <br>  
    <button mat-raised-button type='submit'>Add</button>  
</form>
Enter fullscreen mode Exit fullscreen mode

This is the form for adding the to-do item. It only has one field, the description, to keep it simple.

In add-todo-dialog.component.scss, to change the width of the form field, we put:

form {  
  mat-form-field {  
    width: 100%;  
    margin: 0 auto;  
  }  
}
Enter fullscreen mode Exit fullscreen mode

Next, we build our home page. This is where the two lists will reside. The user can drag and drop between the two lists to change the status of the task.

In home-page.component.ts, we put:

import { Component, OnInit } from '[@angular/core](http://twitter.com/angular/core)';  
import { MatDialog } from '[@angular/material](http://twitter.com/angular/material)/dialog';  
import { AddTodoDialogComponent } from '../add-todo-dialog/add-todo-dialog.component';  
import { TodoService } from '../todo.service';  
import { Store, select } from [@ngrx/store';  
import { SET_TODOS } from '../reducers/todo-reducer';  
import { CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';

@Component({  
  selector: 'app-home-page',  
  templateUrl: './home-page.component.html',  
  styleUrls: ['./home-page.component.scss']  
})  
export class HomePageComponent implements OnInit {  
  allTasks: any[] = [];  
  todo: any[] = [];  
  done: any[] = []; 

  constructor(  
    public dialog: MatDialog,  
    private todoService: TodoService,  
    private store: Store<any>  
  ) {  
    store.pipe(select('todos'))  
      .subscribe(allTasks => {  
        this.allTasks = allTasks || [];  
        this.todo = this.allTasks.filter(t => !t.done);  
        this.done = this.allTasks.filter(t => t.done);  
      })  
  } 

  ngOnInit() {  
    this.getTodos();  
  } 

  openAddTodoDialog() {  
    const dialogRef = this.dialog.open(AddTodoDialogComponent, {  
      width: '70vw',  
      data: {}  
    }) dialogRef.afterClosed().subscribe(result => {  
      console.log('The dialog was closed');  
    });  
  } 

  getTodos() {  
    this.todoService.getTodos()  
      .subscribe(res => {  
        this.store.dispatch({ type: SET_TODOS, payload: res });  
      })  
  } 

  drop(event: CdkDragDrop<any[]>) {  
    if (event.previousContainer === event.container) {  
      moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);  
    } else {  
      transferArrayItem(event.previousContainer.data,  
        event.container.data,  
        event.previousIndex,  
        event.currentIndex);  
    }  
    let data = event.container.data[0];  
    data.done = !data.done;  
    this.todoService.editTodo(data)  
      .subscribe(res => {})  
  } 

  removeTodo(index: number, tasks: any[]) {  
    const todoId = tasks[index].id;  
    this.todoService.removeTodo(todoId)  
      .subscribe(res => {  
        this.getTodos();  
      })  
  }  
}
Enter fullscreen mode Exit fullscreen mode

We have functions to handle the dropping of the to-do items and we let users open the add to-do dialog that we built earlier with the openAddTodoDialog function.

User can also remove to-do’s on this page with the removeTodo function. The drop function handles the dropping between lists and also toggles the status of the to-do item.

The order of this is important. The if...else block should come before calling the editTodo function because the item will not be in the event.container.data array otherwise.

The removeTodo takes both an index and the list of tasks because it is used for both the todo and done arrays.

In home-page.component.html, we add:

<div class="center">  
    <h1>Todos</h1>  
    <button mat-raised-button (click)='openAddTodoDialog()'>Add Todo</button>  
</div>  
<div class="content">  
    <div class="todo-container">  
        <h2>To Do</h2><div cdkDropList #todoList="cdkDropList" [cdkDropListData]="todo" [cdkDropListConnectedTo]="[doneList]"  
            class="todo-list" (cdkDropListDropped)="drop($event)">  
            <div class="todo-box" *ngFor="let item of todo; let i = index" cdkDrag>  
                {{item.description}}  
                <a class="delete-button" (click)='removeTodo(i, todo)'>  
                    <i class="material-icons">  
                        close  
                    </i>  
                </a>  
            </div>  
        </div>  
    </div><div class="done-container">  
        <h2>Done</h2><div cdkDropList #doneList="cdkDropList" [cdkDropListData]="done" [cdkDropListConnectedTo]="[todoList]"  
            class="todo-list" (cdkDropListDropped)="drop($event)">  
            <div class="todo-box" *ngFor="let item of done; let i = index" cdkDrag>  
                {{item.description}}  
                <a class="delete-button" (click)='removeTodo(i, done)'>  
                    <i class="material-icons">  
                        close  
                    </i>  
                </a>  
            </div>  
        </div>  
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

This template provides the two lists for the user to drag and drop between to toggle the status. There is also an ‘x’ button is each list item to let users delete the item.

In home-page.component.scss, we add:

$gray: gray;.content {  
  display: flex;  
  align-items: flex-start;  
  margin-left: 2vw;  
  div {  
    width: 45vw;  
  }  
}

.todo-container {  
  width: 400px;  
  max-width: 100%;  
  margin: 0 25px 25px 0;  
  display: inline-block;  
  vertical-align: top;  
}

.todo-list {  
  border: solid 1px $gray;  
  min-height: 70px;  
  background: white;  
  border-radius: 4px;  
  overflow: hidden;  
  display: block;  
}

.todo-box {  
  padding: 20px 10px;  
  border-bottom: solid 1px $gray;  
  color: rgba(0, 0, 0, 3);  
  display: flex;  
  flex-direction: row;  
  align-items: center;  
  justify-content: space-between;  
  box-sizing: border-box;  
  cursor: move;  
  background: white;  
  font-size: 14px;  
  height: 70px;  
}

.cdk-drag-preview {  
  box-sizing: border-box;  
  border-radius: 4px;  
  box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 2), 0 8px 10px 1px rgba(0, 0, 0, 1), 0 3px 14px 2px rgba(0, 0, 0, 6);  
}

.cdk-drag-placeholder {  
  opacity: 0;  
}

.cdk-drag-animating {  
  transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);  
}

.todo-box:last-child {  
  border: none;  
}

.todo-list.cdk-drop-list-dragging .todo-box:not(.cdk-drag-placeholder) {  
  transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);  
}

.delete-button {  
  cursor: pointer;  
}
Enter fullscreen mode Exit fullscreen mode

To style the lists and the boxes so they have a border and a shadow.

We move on to adding the reducers for our store. We create a file, called menu-reducer.ts, by running ng g class menuReducer.

In there, we add:

export const SET_MENU_STATE = 'SET_MENU_STATE';

export function MenuReducer(state: boolean, action) {  
    switch (action.type) {  
        case SET_MENU_STATE:  
            return action.payload; 
        default:  
            return state;  
    }  
}
Enter fullscreen mode Exit fullscreen mode

Similarly, we create a file, called todo-reducer.ts, by running ng g class todoReducer, and add:

const SET_TODOS = 'SET_TODOS';

function todoReducer(state, action) {  
    switch (action.type) {  
        case SET_TODOS:  
            state = action.payload;  
            return state;  
        default:  
            return state  
    }  
}

export { todoReducer, SET_TODOS };
Enter fullscreen mode Exit fullscreen mode

In reducers/index.ts, we put:

import { MenuReducer } from './menu-reducer';  
import { todoReducer } from './todo-reducer';
export const reducers = {  
  menuState: MenuReducer,  
  todos: todoReducer  
};
Enter fullscreen mode Exit fullscreen mode

So that the reducers can be passed into the StoreModule when we import it in app.module.ts. The three files together will create the store that we use to store all the to-do tasks.

Next, in tool-bar.component.ts, we put:

import { Component, OnInit } from '@angular/core';  
import { Store, select } from '@ngrx/store';  
import { SET_MENU_STATE } from '../reducers/menu-reducer';

@Component({  
  selector: 'app-tool-bar',  
  templateUrl: './tool-bar.component.html',  
  styleUrls: ['./tool-bar.component.scss']  
})  
export class ToolBarComponent implements OnInit {  
  menuOpen: boolean; constructor(  
    private store: Store<any>  
  ) {  
    store.pipe(select('menuState'))  
      .subscribe(menuOpen => {  
        this.menuOpen = menuOpen;  
      })  
  } 

  ngOnInit() {  
  }

  toggleMenu() {  
    this.store.dispatch({ type: SET_MENU_STATE, payload: !this.menuOpen });  
  }
}
Enter fullscreen mode Exit fullscreen mode

To let users toggle the left menu on and off. Then, in tool-bar.component.html, we put:

<mat-toolbar>  
    <a (click)='toggleMenu()' class="menu-button">  
        <i class="material-icons">  
            menu  
        </i>  
    </a>  
    Todo App  
</mat-toolbar>
Enter fullscreen mode Exit fullscreen mode

To add the top toolbar and the menu.

In tool-bar.component.scss, we add:

.menu-button {  
  margin-top: 6px;  
  margin-right: 10px;  
  cursor: pointer;  
}

.mat-toolbar {  
  background: #009688;  
  color: white;  
}
Enter fullscreen mode Exit fullscreen mode

To add some spacing to our menu button and title text.

In app-routing.module.ts, we replace the existing code with:

import { NgModule } from '@angular/core';  
import { Routes, RouterModule } from '@angular/router';  
import { HomePageComponent } from './home-page/home-page.component';

const routes: Routes = [  
  { path: '', component: HomePageComponent },  
];

@NgModule({  
  imports: [RouterModule.forRoot(routes\],  
  exports: [RouterModule]  
})  
export class AppRoutingModule { }
Enter fullscreen mode Exit fullscreen mode

To let users see the home page.

Then, in app.component.ts, we put:

import { Component, HostListener } from '@angular/core';  
import { SET_MENU_STATE } from './reducers/menu-reducer';  
import { Store, select } from '@ngrx/store';

@Component({  
  selector: 'app-root',  
  templateUrl: './app.component.html',  
  styleUrls: ['./app.component.scss']  
})  
export class AppComponent {  
  menuOpen: boolean; constructor(  
    private store: Store<any>,  
  ) {  
    store.pipe(select('menuState'))  
      .subscribe(menuOpen => {  
        this.menuOpen = menuOpen;  
      })  
  } 

  @HostListener('document:click', ['$event'])  
  public onClick(event) {  
    const isOutside = !event.target.className.includes("menu-button") &&  
      !event.target.className.includes("material-icons") &&  
      !event.target.className.includes("mat-drawer-inner-container")  
    if (isOutside) {  
      this.menuOpen = false;  
      this.store.dispatch({ type: SET_MENU_STATE, payload: this.menuOpen });  
    }  
  }
}
Enter fullscreen mode Exit fullscreen mode

To toggle the menu off when the user clicks outside of the menu button and menu. In app.component.html, we add:

<mat-sidenav-container class="example-container">  
  <mat-sidenav mode="side" [opened]='menuOpen'>  
      <ul>  
          <li>  
              <b>  
                  New York Times  
              </b>  
          </li>  
          <li>  
              <a routerLink='/'>Home</a>  
          </li>  
      </ul>
  </mat-sidenav>  
  <mat-sidenav-content>  
      <app-tool-bar></app-tool-bar>  
      <div id='content'>  
          <router-outlet></router-outlet>  
      </div>  
  </mat-sidenav-content>  
</mat-sidenav-container>
Enter fullscreen mode Exit fullscreen mode

To add the menu, the left-side navigation, and router-outlet element to let people see the routes we defined.

In app.component.scss, we add:

#content {  
  padding: 20px;  
  min-height: 100vh;  
}

ul {  
  list-style-type: none;  
  margin: 0;  
  li {  
    padding: 20px 5px;  
  }  
}
Enter fullscreen mode Exit fullscreen mode

To add some padding to the pages and change the list style of the items in the left side menu.

In environment.ts, we add:

export const environment = {  
  production: false,  
  apiUrl: 'http://localhost:3000'
};
Enter fullscreen mode Exit fullscreen mode

To add the URL for our API.

In styles.scss, we add:

/* You can add global styles to this file, and also import other style files */  
@import "~@angular/material/prebuilt-themes/indigo-pink.css";

body {  
  font-family: "Roboto", sans-serif;  
  margin: 0;  
}

form {  
  mat-form-field {  
    width: 95vw;  
    margin: 0 auto;  
  }  
}

.center {  
  text-align: center;  
}
Enter fullscreen mode Exit fullscreen mode

To import the Material Design theme and change the form field’s widths.

In app.module.ts, we replace the existing code with:

import { BrowserModule } from '@angular/platform-browser';  
import { NgModule } from '@angular/core';  
import { FormsModule } from '@angular/forms';  
import { AppRoutingModule } from './app-routing.module';  
import { AppComponent } from './app.component';  
import { HomePageComponent } from './home-page/home-page.component';  
import { StoreModule } from '@ngrx/store';  
import { reducers } from './reducers';  
import { MatSidenavModule } from '@angular/material/sidenav';  
import { MatToolbarModule } from '@angular/material/toolbar';  
import { MatInputModule } from '@angular/material/input';  
import { MatFormFieldModule } from '@angular/material/form-field';  
import { ToolBarComponent } from './tool-bar/tool-bar.component';  
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';  
import { MatButtonModule } from '@angular/material/button';  
import { HttpClientModule } from '@angular/common/http';  
import { MatSelectModule } from '@angular/material/select';  
import { MatCardModule } from '@angular/material/card';  
import { MatListModule } from '@angular/material/list';  
import { MatMenuModule } from '@angular/material/menu';  
import { MatIconModule } from '@angular/material/icon';  
import { MatGridListModule } from '@angular/material/grid-list';  
import { AddTodoDialogComponent } from './add-todo-dialog/add-todo-dialog.component';  
import { DragDropModule } from '@angular/cdk/drag-drop';

@NgModule({  
  declarations: [  
    AppComponent,  
    HomePageComponent,  
    ToolBarComponent,  
    AddTodoDialogComponent  
  ],  
  imports: [  
    BrowserModule,  
    AppRoutingModule,  
    StoreModule.forRoot(reducers),  
    FormsModule,  
    MatSidenavModule,  
    MatToolbarModule,  
    MatInputModule,  
    MatFormFieldModule,  
    BrowserAnimationsModule,  
    MatButtonModule,  
    MatMomentDateModule,  
    HttpClientModule,  
    MatSelectModule,  
    MatCardModule,  
    MatListModule,  
    MatMenuModule,  
    MatIconModule,  
    MatGridListModule,  
    DragDropModule  
  ],  
  providers: [],  
  bootstrap: [AppComponent],  
  entryComponents: [  
    AddTodoDialogComponent  
  ]  
})  
export class AppModule { }
}
Enter fullscreen mode Exit fullscreen mode

In todo.service.ts, we put:

import { Injectable } from '@angular/core';  
import { HttpClient } from '@angular/common/http';  
import { environment } from 'src/environments/environment';

@Injectable({  
  providedIn: 'root'  
})  
export class TodoService { 

  constructor(  
    private http: HttpClient  
  ) { } 

  getTodos() {  
    return this.http.get(`${environment.apiUrl}/todos`);  
  } 

  addTodo(data) {  
    return this.http.post(`${environment.apiUrl}/todos`, data);  
  } 

  editTodo(data) {  
    return this.http.put(`${environment.apiUrl}/todos/${data.id}`, data);  
  } 

  removeTodo(id) {  
    return this.http.delete(`${environment.apiUrl}/todos/${id}`);  
  }  
}
Enter fullscreen mode Exit fullscreen mode

These functions let us do CRUD operations for our to-do items by making requests to our JSON API, which we will be added using the JSON Server Node.js package.

Data will be saved to a JSON file, so we don’t have to make our own back end add it, to save some simple data. We install the server by running npm i -g json-server.

Once that is done, go into the project directory and run json-server --watch db.json. In db.json, we put:

{  
  "todos": []  
}
Enter fullscreen mode Exit fullscreen mode

So that we can use those endpoints for saving data to db.json.

Top comments (3)

Collapse
 
x777 profile image
YD

It's useful for future projects. Thx!

Collapse
 
aumayeung profile image
John Au-Yeung

Thanks very much for reading!

Collapse
 
milindnigam profile image
milind-nigam • Edited

I need help with this project, can you help me out?
this is how the app is as of now, i copied your code as is, if you can give me a demo it would be a great help