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
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 });
})
}
}
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>
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;
}
}
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();
})
}
}
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>
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;
}
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;
}
}
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 };
In reducers/index.ts
, we put:
import { MenuReducer } from './menu-reducer';
import { todoReducer } from './todo-reducer';
export const reducers = {
menuState: MenuReducer,
todos: todoReducer
};
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 });
}
}
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>
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;
}
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 { }
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 });
}
}
}
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>
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;
}
}
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'
};
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;
}
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 { }
}
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}`);
}
}
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": []
}
So that we can use those endpoints for saving data to db.json
.
Top comments (3)
It's useful for future projects. Thx!
Thanks very much for reading!
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