Создание приложения с функциями перетаскивания, от дел к выполнению

Перетаскивание - это функция многих интерактивных веб-приложений. Он предоставляет пользователям интуитивно понятный способ манипулировать своими данными. Для приложений Angular легко добавить функцию перетаскивания.

В этой части мы создадим приложение с двумя столбцами: столбец to-do и столбец Готово. Вы можете перетаскивать между ними два, чтобы изменить статус с дел на выполненное и наоборот.

Для создания приложения мы используем библиотеку Angular Material, чтобы приложение выглядело хорошо и с легкостью предоставляли возможность перетаскивания в наше приложение. У него также будет меню навигации и верхняя панель.

Чтобы начать сборку приложения, мы устанавливаем Angular CLI, запустив npm i @angular/cli. Мы должны включить маршрутизацию и использовать SCSS, когда вас об этом попросят.

Затем мы создаем новый проект Angular, запустив ng new todo-app. После этого добавляем нужные нам библиотеки, запустив npm i@angular/cdk @angular/material @ngrx/store.

Это добавит в наше приложение Angular Material и магазин NGRX. Мы будем широко использовать Flux с этим приложением. Затем мы добавляем наши компоненты и службы, выполнив следующее:

ng g component addTodoDialog
ng g component homePage
ng g component toolBar
ng g service todo

Мы добавляем шаблон для магазина NGRX, запустив ng add @ngrx/store.

Теперь мы можем построить логику для нашего приложения. В add-todo-dialog.component.ts мы добавляем:

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 });
      })
  }
}

Этот код предназначен для диалогового окна, которое позволяет нам добавлять элементы в список, затем получать последние элементы и помещать их в магазин.

В add-todo-dialog.component.html мы добавляем:

<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>

Это форма для добавления задачи. Для простоты у него есть только одно поле - описание.

В add-todo-dialog.component.scss, чтобы изменить ширину поля формы, мы помещаем:

form {
  mat-form-field {
    width: 100%;
    margin: 0 auto;
  }
}

Далее мы создаем нашу домашнюю страницу. Здесь будут находиться два списка. Пользователь может перетаскивать между двумя списками, чтобы изменить статус задачи.

В home-page.component.ts мы помещаем:

import { Component, OnInit } from '@angular/core';
import { MatDialog } from '@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();
      })
  }
}

У нас есть функции для обработки удаления элементов списка дел, и мы позволяем пользователям открывать диалоговое окно добавить дело, которое мы создали ранее с помощью функции openAddTodoDialog.

Пользователь также может удалить задачи на этой странице с помощью функции removeTodo. Функция drop обрабатывает переход между списками, а также переключает статус элемента списка дел.

Порядок этого важен. Блок if...else должен стоять перед вызовом функции editTodo, потому что иначе элемент не будет в массиве event.container.data.

removeTodo принимает как index, так и список tasks, потому что он используется как для массивов todo, так и done.

В home-page.component.html мы добавляем:

<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>

Этот шаблон предоставляет два списка, которые пользователь может перетаскивать между собой для переключения статуса. Также есть кнопка «x» для каждого элемента списка, позволяющая пользователям удалить этот элемент.

В home-page.component.scss мы добавляем:

$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;
}

Чтобы стилизовать списки и поля, чтобы у них была граница и тень.

Переходим к добавлению редукторов для нашего магазина. Мы создаем файл с именем menu-reducer.ts, запустив ng g class menuReducer.

Там мы добавляем:

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;
    }
}

Точно так же мы создаем файл с именем todo-reducer.ts, запустив ng g class todoReducer, и добавляем:

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 };

В reducers/index.ts мы помещаем:

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

Чтобы редукторы можно было передать в StoreModule, когда мы импортируем его в app.module.ts. Вместе эти три файла создадут хранилище, которое мы используем для хранения всех текущих задач.

Далее, в tool-bar.component.ts, мы помещаем:

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 });
  }
}

Чтобы пользователи могли включать и выключать левое меню. Затем в tool-bar.component.html мы помещаем:

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

Добавить верхнюю панель инструментов и меню.

В tool-bar.component.scss мы добавляем:

.menu-button {
  margin-top: 6px;
  margin-right: 10px;
  cursor: pointer;
}
.mat-toolbar {
  background: #009688;
  color: white;
}

Чтобы добавить некоторый интервал к нашей кнопке меню и тексту заголовка.

В app-routing.module.ts мы заменяем существующий код на:

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 { }

Чтобы пользователи могли видеть домашнюю страницу.

Затем в app.component.ts мы помещаем:

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 });
    }
  }
}

Для выключения меню, когда пользователь щелкает за пределами кнопки меню и меню. В app.component.html мы добавляем:

<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>

Чтобы добавить меню, левую панель навигации и элемент router-outlet, чтобы люди могли видеть маршруты, которые мы определили.

В app.component.scss мы добавляем:

#content {
  padding: 20px;
  min-height: 100vh;
}
ul {
  list-style-type: none;
  margin: 0;
  li {
    padding: 20px 5px;
  }
}

Чтобы добавить отступы на страницы и изменить стиль списка элементов в левом боковом меню.

В environment.ts мы добавляем:

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

Чтобы добавить URL-адрес нашего API.

В styles.scss мы добавляем:

/* 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;
}

Импортировать тему Material Design и изменить ширину поля формы.

В app.module.ts мы заменяем существующий код на:

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 { }
text-align: center;
}

В todo.service.ts мы помещаем:

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}`);
  }
}

Эти функции позволяют нам выполнять операции CRUD для наших дел, делая запросы к нашему JSON API, который мы добавим с помощью пакета JSON Server Node.js.

Данные будут сохранены в файл JSON, поэтому нам не нужно заставлять нашу собственную серверную часть добавлять их, чтобы сохранить некоторые простые данные. Устанавливаем сервер, запустив npm i -g json-server.

Как только это будет сделано, перейдите в каталог проекта и запустите json-server --watch db.json. В db.json мы помещаем:

{
  "todos": []
}

Чтобы мы могли использовать эти конечные точки для сохранения данных в db.json.

После того, как все сделано, получаем: