Angular 实战教程 - 手把手教你构建待办事项应用 Today (Part 3)

2018-07-12 admin

Angular 实战教程 - 手把手教你构建待办事项应用 Today (Part 3)

目录和链接

本系列文章共有以下几篇(更新中):

  1. Angular 实战教程 - 手把手教你构建待办事项应用 Today (Part 1)
  2. Angular 实战教程 - 手把手教你构建待办事项应用 Today (Part 2):编写主界面左侧的 left-control
  3. (本篇)编写主界面右侧的 right-control,实现列表排序和建议

你可以点击下面的链接预览:

Today

点击下面的链接获取源代码:

today-ng

点击下面的链接获取按照行文顺序 commit 的源代码:

wendzhue/today-ng-steps


在上一篇文章中我们完成了主界面的左边部分,在这篇文章中我们会将右边部分也完成。

right-control

从 Demo 中可以看到,右边主要分为三个部分,一是上方的 header 区域,展示当前列表名称,建议按钮和排序按钮,二是当前列表下待办事项的显示区域,三是快速添加待办事项的输入框。我们先来生成这些组件:

ng g c pages/main/right-control
ng g c pages/main/right-control/header
ng g c pages/main/right-control/quick-add
ng g c pages/main/right-control/todo

在 right-control.component.html 中先规划好页面结构:

<nz-layout class="right-control">
  <nz-header class="header-wrapper">
  </nz-header>
  <nz-content class="list-wrapper">
  </nz-content>
  <div class="quick-add-wrapper">
  </div>
</nz-layout>

接下来我们先写 Todo 组件。

Todo

在 todo.component.html 中输入如下内容:

<nz-list *ngIf="todos.length > 0"
         [nzDataSource]="todos"
         [nzRenderItem]="item"
         [nzItemLayout]="'horizontal'">
  <ng-template #item
               let-item>
    <nz-list-item class="todo-item"
                  (click)="click(item._id)">
      <nz-list-item-meta [nzTitle]="nzTitle">
        <ng-template #nzTitle>
          <label nz-checkbox
                 (click)="$event.stopPropagation()"
                 [(ngModel)]="item.completedFlag"
                 (ngModelChange)="toggle(item._id)"></label>
          <span [class.strikethrough]="item.completedFlag">{{ item.title }}</span>
        </ng-template>
      </nz-list-item-meta>
    </nz-list-item>
  </ng-template>
</nz-list>

在 todo.component.ts 输入如下内容:

import { Component, OnInit, OnDestroy } from '@angular/core';
import { Router } from '@angular/router';
import { NzDropdownService } from 'ng-zorro-antd';
import { combineLatest, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { Todo, List } from '../../../../../domain/entities';
import { TodoService } from '../../../../services/todo/todo.service';
import { ListService } from '../../../../services/list/list.service';
import { floorToDate, getTodayTime } from '../../../../../utils/time';

@Component({
  selector: 'app-todo',
  templateUrl: './todo.component.html',
  styleUrls: [ './todo.component.less' ]
})
export class TodoComponent implements OnInit, OnDestroy {
  private destory$ = new Subject();

  todos: Todo[] = [];
  lists: List[] = [];
  currentContextTodo: Todo;

  constructor(
    private listService: ListService,
    private todoService: TodoService
  ) { }

  ngOnInit() {
    this.listService.lists$
      .pipe(takeUntil(this.destory$))
      .subscribe(lists => {
        this.lists = lists;
      });

    combineLatest(this.listService.currentUuid$, this.todoService.todo$)
      .pipe(takeUntil(this.destory$))
      .subscribe(sources => {
        this.processTodos(sources[ 0 ], sources[ 1 ]);
      });

    this.todoService.getAll();
    this.listService.getAll();
  }

  ngOnDestroy() {
    this.destory$.next();
  }

  private processTodos(listUUID: string, todos: Todo[]): void {
    const filteredTodos = todos
      .filter(todo => {
        return ((listUUID === 'today' && todo.planAt && floorToDate(todo.planAt) <= getTodayTime())
          || (listUUID === 'todo' && (!todo.listUUID || todo.listUUID === 'todo'))
          || (listUUID === todo.listUUID));
      })
      .map(todo => Object.assign({}, todo) as Todo);

    this.todos = [].concat(filteredTodos);
  }

  add(title: string): void {
    this.todoService.add(title);
  }
}

我们在之前一篇文章中曾经讨论过为什么不直接访问 service 的属性,而用一种看起来比较麻烦的订阅机制,这里就展现出原因了:借助 rxjs 的强大威力,我们在当前列表改变的时候,不需要命令式地去修改列表,因为我们同样订阅了当前列表改变的事件!通过 combineLastest 操作符,不管是待办事项发生了变化,还是当前列表发生了变化,我们都能得到通知并计算出此时应当显示在屏幕上的待办事项。我们之后会做的排序,同样通过这种机制监听排序依据改变的事件。如果你想要增加更多的功能,良好的可拓展性是很有必要的。

如果你不了解 RxJS,可以看这篇文章:

VTHINKXIE:Angular资料获取不完全指南

Quick Add

现在有了 Todo 组件,但还无法显示待办事项清单——因为我们还办法添加待办事项呢!

我们需要 Quick Add 组件,它是个浮动的输入框,用户输入待办事项标题后按下回车,就可以添加一条待办事项。

在 quick-add.component.html 中输入:

<input nz-input
       #addInput
       placeholder="想要做什么?"
       (keydown.enter)="addTodo(addInput.value); addInput.value = ''">

在 quick-add.component.ts 中输入:

import { Component, EventEmitter, OnInit, Output } from '@angular/core';

@Component({
  selector: 'app-quick-add',
  templateUrl: './quick-add.component.html',
  styleUrls: [ './quick-add.component.less' ]
})
export class QuickAddComponent implements OnInit {
  @Output() add = new EventEmitter<string>();

  constructor() { }

  ngOnInit() {
  }

  addTodo(title: string) {
    if (title) { this.add.next(title); }
  }
}

然后在 right-control.component.html 中引入这两个组件:

<nz-layout class="right-control">
  <nz-header class="header-wrapper">
  </nz-header>
  <nz-content class="list-wrapper">
    <app-todo></app-todo>
  </nz-content>
  <div class="quick-add-wrapper">
    <app-quick-add></app-quick-add>
  </div>
</nz-layout>

预览,效果如下:

可以看到,输入之后回车,并没有实现我们设计的效果,这是因为我们没有触发 Todo 组件中的方法。在 right-control.component.html 中作出如下修改:

<app-quick-add (add)="add($event)"></app-quick-add>

然后在 right-control.component.ts 中添加方法:

import { Component, OnInit, ViewChild } from '@angular/core';
import { TodoComponent } from './todo/todo.component';

@Component({
  selector: 'app-right-control',
  templateUrl: './right-control.component.html',
  styleUrls: [ './right-control.component.less' ]
})
export class RightControlComponent implements OnInit {
  ...

  add(title: string) {
    this.todoList.add(title);
  }
}

这时候应用就按照我们的设计工作了!

体验提升

但是现在的易用性显然无法令我们满意,让我们来增加功能。

首先,我们要在待办事项设定了截止日期、计划日期和详情的时候,在主界面进行显示。然后,我们要为待办事项添加右键菜单,让用户能够方便地使用移动到别的列表、设置计划日期和删除等功能。

修改 todo.component.html 文件:

<nz-list *ngIf="todos.length > 0"
         [nzDataSource]="todos"
         [nzRenderItem]="item"
         [nzItemLayout]="'horizontal'">
  <ng-template #item
               let-item>
    <nz-list-item class="todo-item"
                  (click)="click(item._id)"
                  (contextmenu)="contextMenu($event, todoContextRef, item._id)">
      <nz-list-item-meta [nzTitle]="nzTitle"
                         [nzDescription]="nzDescription">
        <ng-template #nzTitle>
          <label nz-checkbox
                 (click)="$event.stopPropagation()"
                 [(ngModel)]="item.completedFlag"
                 (ngModelChange)="toggle(item._id)"></label>
          <span [class.strikethrough]="item.completedFlag">{{ item.title }}</span>
        </ng-template>
        <ng-template #nzDescription>
          <span *ngIf="item.dueAt"
                class="todo-desc">
            <i class="anticon anticon-calendar"></i> {{ item.dueAt | date }}</span>
          <span *ngIf="item.planAt"
                class="todo-desc">
            <i class="anticon anticon-clock-circle-o"></i> {{ item.planAt | date }}</span>
          <span *ngIf="item.notifyMe"
                class="todo-desc">
            <i class="anticon anticon-bell"></i>
          </span>
          <span *ngIf="item.desc">
            <i class="anticon anticon-edit"></i>
          </span>
        </ng-template>
      </nz-list-item-meta>
    </nz-list-item>
  </ng-template>
</nz-list>

<ng-template #todoContextRef>
  <ul nz-menu
      nzInDropDown
      (nzClick)="close()">
    <li nz-menu-item
        (click)="setToday()">
      <i class="anticon anticon-home anticon-right-margin"></i>
      <span>设为今日</span>
    </li>
    <li nz-submenu>
      <span title>
        <i class="anticon anticon-bars anticon-right-margin"></i>移动到...</span>
      <ul>
        <li nz-menu-item
            *ngIf="currentContextTodo?.listUUID !== 'todo'"
            (click)="moveToList('todo')">
          默认列表
        </li>
        <li nz-menu-item
            *ngFor="let list of listsExcept(currentContextTodo.listUUID)"
            (click)="moveToList(list._id)">
          {{ list.title }}
        </li>
      </ul>
    </li>
    <li nz-menu-divider></li>
    <li nz-menu-item
        (click)="delete()">
      <i class="anticon anticon-delete anticon-right-margin danger"></i>
      <span class="danger">删除</span>
    </li>
  </ul>
</ng-template>

以及 todo.component.ts 文件:

import { Component, OnInit, OnDestroy, TemplateRef } from '@angular/core';
import { NzDropdownService, NzDropdownContextComponent } from 'ng-zorro-antd';
import { combineLatest, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { Todo, List } from '../../../../../domain/entities';
import { TodoService } from '../../../../services/todo/todo.service';
import { ListService } from '../../../../services/list/list.service';
import { floorToDate, getTodayTime } from '../../../../../utils/time';

@Component({
  selector: 'app-todo',
  templateUrl: './todo.component.html',
  styleUrls: [ './todo.component.less' ]
})
export class TodoComponent implements OnInit, OnDestroy {
  private dropdown: NzDropdownContextComponent;
  private destory$ = new Subject();

  todos: Todo[] = [];
  lists: List[] = [];
  currentContextTodo: Todo;

  constructor(
    private listService: ListService,
    private todoService: TodoService,
    private dropdownService: NzDropdownService
  ) { }

  ngOnInit() {
    this.listService.lists$
      .pipe(takeUntil(this.destory$))
      .subscribe(lists => {
        this.lists = lists;
      });

    combineLatest(this.listService.currentUuid$, this.todoService.todo$)
      .pipe(takeUntil(this.destory$))
      .subscribe(sources => {
        this.processTodos(sources[ 0 ], sources[ 1 ]);
      });

    this.todoService.getAll();
    this.listService.getAll();
  }

  ngOnDestroy() {
    this.destory$.next();
  }

  private processTodos(listUUID: string, todos: Todo[]): void {
    const filteredTodos = todos
      .filter(todo => {
        return ((listUUID === 'today' && todo.planAt && floorToDate(todo.planAt) <= getTodayTime())
          || (listUUID === 'todo' && (!todo.listUUID || todo.listUUID === 'todo'))
          || (listUUID === todo.listUUID));
      })
      .map(todo => Object.assign({}, todo) as Todo);

    this.todos = [].concat(filteredTodos);
  }

  add(title: string): void {
    this.todoService.add(title);
  }

  contextMenu(
    $event: MouseEvent,
    template: TemplateRef<void>,
    uuid: string
  ): void {
    this.dropdown = this.dropdownService.create($event, template);
    this.currentContextTodo = this.todos.find(t => t._id === uuid);
  }

  listsExcept(listUUID: string): List[] {
    return this.lists.filter(l => l._id !== listUUID);
  }

  toggle(uuid: string): void {
    this.todoService.toggleTodoComplete(uuid);
  }

  delete(): void {
    this.todoService.delete(this.currentContextTodo._id);
  }

  setToday(): void {
    this.todoService.setTodoToday(this.currentContextTodo._id);
  }

  moveToList(listUuid: string): void {
    this.todoService.moveToList(this.currentContextTodo._id, listUuid);
  }

  close(): void {
    this.dropdown.close();
  }
}

<figcaption>添加了右键菜单之后的效果</figcaption>

header

我们现在来做 header 部分,可以看到它主要提供了两个功能:一是点击“建议”按钮后,就可以弹出一个 dropdown 来让用户选择适合今天完成的任务;二是点击“排序”按钮后,就会弹出一个选择框让用户选择待办事项的排序方式。

我们前面已经提到,在实现排序的时候,我们要用订阅发布模式,接下来我们就来实现它。

在 todo.service.ts 中为 TodoService 类增加如下属性和方法:

@Injectable()
export class TodoService {
  todo$ = new Subject<Todo[]>();
  rank$ = new Subject<RankBy>();

  private todos: Todo[] = [];
  private rank: RankBy = 'title';

  // 还要修改这个方法
  private broadCast(): void {
    this.todo$.next(this.todos);
    this.rank$.next(this.rank);
  }

  toggleRank(r: RankBy): void {
    this.rank = r;
    this.rank$.next(r);
  }
}

然后,我们来编写 header.component.html 文件:

<div class="header-container">
  <img class="background-img" src="./assets/img/logo.png" alt="">
  <div class="list-title-wrapper">
    {{ listTitle }}
  </div>
  <div class="suggest-btn-wrapper">
    <nz-dropdown [nzTrigger]="'click'" [nzClickHide]="false" [nzPlacement]="'bottomRight'">
      <button nz-dropdown nz-button [nzType]="'primary'">
        <i class="anticon anticon-bulb"></i>建议
      </button>
      <!-- hack nz-zorro! -->
      <div nz-menu class="dropdown-content-wrapper">
      </div>
    </nz-dropdown>
  </div>
  <div class="sort-btn-wrapper">
    <nz-dropdown [nzPlacement]="'bottomRight'">
      <a nz-dropdown>
        排序
        <i class="anticon anticon-down"></i>
      </a>
      <ul nz-menu nzSelectable>
        <li nz-menu-item (click)="switchRankType('title')">
          名称
        </li>
        <li nz-menu-item (click)="switchRankType('planAt')">
          计划时间
        </li>
        <li nz-menu-item (click)="switchRankType('dueAt')">
          截止时间
        </li>
        <li nz-menu-item (click)="switchRankType('completeFlag')">
          完成状态
        </li>
      </ul>
    </nz-dropdown>
  </div>
</div>

你可以尝试去掉 <div nz-menu class="dropdown-content-wrapper"> 中的 nz-menu,会发现下拉动画没有了。官方文档并不会告诉你类似这样的奇技淫巧,而自己去实现这个动画则破费周折,所以了解一下你所用的库的工作细节还是很有意义的。

header.component.ts 文件:

import { Component, OnInit } from '@angular/core';
import { Subscription } from 'rxjs';
import { RankBy } from '../../../../../domain/type';
import { ListService } from '../../../../services/list/list.service';
import { TodoService } from '../../../../services/todo/todo.service';

@Component({
  selector: 'app-header',
  templateUrl: './header.component.html',
  styleUrls: [ './header.component.less' ]
})
export class HeaderComponent implements OnInit {
  private listTitle$: Subscription;

  listTitle = '';

  constructor(
    private listService: ListService,
    private todoService: TodoService
  ) { }

  ngOnInit() {
    this.listTitle$ = this.listService.current$.subscribe(list => {
      this.listTitle = list ? list.title : '';
    });
  }

  switchRankType(e: RankBy): void {
    this.todoService.toggleRank(e);
  }
}

不要忘记在 right-control.component.html 中引入这个组件。

尝试在应用里多创建几个待办事项,然后用名称进行排序,会发现没有变化,这是因为我们没有相应地修改 Todo 组件里面的发布订阅机制。不过这个改动也非常简单:

const rankerGenerator = (type: RankBy = 'title'): any => {
  if (type === 'completeFlag') {
    return (t1: Todo, t2: Todo) => t1.completedFlag && !t2.completedFlag;
  }
  return (t1: Todo, t2: Todo) => t1[ type ] > t2[ type ];
};

export class TodoComponent implements OnInit, OnDestroy {
  // ...

  ngOnInit() {
    this.listService.lists$
      .pipe(takeUntil(this.destory$))
      .subscribe(lists => {
        this.lists = lists;
      });

    combineLatest(this.listService.currentUuid$, this.todoService.todo$, this.todoService.rank$)
      .pipe(takeUntil(this.destory$))
      .subscribe(sources => {
        this.processTodos(sources[ 0 ], sources[ 1 ], sources[ 2 ]);
      });

    this.todoService.getAll();
    this.listService.getAll();
  }

  ngOnDestroy() {
    this.destory$.next();
  }

  private processTodos(listUUID: string, todos: Todo[], rank: RankBy): void {
    const filteredTodos = todos
      .filter(todo => {
        return ((listUUID === 'today' && todo.planAt && floorToDate(todo.planAt) <= getTodayTime())
          || (listUUID === 'todo' && (!todo.listUUID || todo.listUUID === 'todo'))
          || (listUUID === todo.listUUID));
      })
      .map(todo => Object.assign({}, todo) as Todo)
      .sort(rankerGenerator(rank));

    this.todos = [].concat(filteredTodos);
  }

  // ...
}

建议

Today 的一个亮点功能,就是能够根据待办事项的计划日期和截止日期推荐适合今日完成任务,让我们来实现这个组件。

ng g c pages/main/right-control/header/suggest

然后编写如下代码:

<div class="suggest-container container">
  <span style="font-weight: 700;">
    接下来打算做什么?请考虑我们的建议!
  </span>
  <nz-list class="suggestion-list" [nzDataSource]="suggestedTodo" [nzRenderItem]="item" [nzItemLayout]="'horizontal'">
    <ng-template #item let-item>
      <nz-list-item class="suggestion-item" [nzActions]="[setTodayAction]">
        <nz-list-item-meta [nzTitle]="item.title" [nzDescription]="item.desc">
          <ng-template #nzTitle>
            <a href="https://ng.ant.design">{{item.name.last}}</a>
          </ng-template>
        </nz-list-item-meta>
        <ng-template #setTodayAction>
          <a (click)="setTodoToday(item)">设为今日</a>
        </ng-template>
      </nz-list-item>
    </ng-template>
  </nz-list>
  <div class="no-suggestion" *ngIf="suggestedTodo.length === 0">
    暂无建议
  </div>
</div>

import { Component, OnDestroy, OnInit } from '@angular/core';
import { Subscription } from 'rxjs';
import { Todo } from '../../../../../../domain/entities';
import { TodoService } from '../../../../../services/todo/todo.service';
import { floorToDate, getTodayTime, ONE_DAY } from '../../../../../../utils/time';

@Component({
  selector: 'app-suggest',
  templateUrl: './suggest.component.html',
  styleUrls: [ './suggest.component.less' ]
})
export class SuggestComponent implements OnInit, OnDestroy {
  suggestedTodo: Todo[] = [];

  private todo$: Subscription;

  constructor(
    private todoService: TodoService
  ) { }

  ngOnInit() {
    this.todo$ = this.todoService.todo$.subscribe(todos => {
      const filtered = todos.filter(t => {
        if (t.planAt && floorToDate(t.planAt) <= getTodayTime()) { return false; }
        if (t.dueAt && t.dueAt - getTodayTime() <= ONE_DAY * 2) { return true; }
        return false;
      });
      this.suggestedTodo = [].concat(filtered);
    });

    this.todoService.getAll();
  }

  ngOnDestroy() {
    this.todo$.unsubscribe();
  }

  setTodoToday(todo: Todo): void { this.todoService.setTodoToday(todo._id); }
}

在 header.component.html 中引入这个组件,我们的主界面就大功告成了(暂时如此)!

<figcaption>这篇文章结束的时候</figcaption>

等等,好像有个很严重的问题!我们在判断哪些待办事项能进入推荐的时候,用了两条逻辑,一是事件的计划日期在今天之前,二是事项的截止日期在两天之内,而现在我们因为没有办法设置待办事项详情,所以似乎没有办法能够手工测试它(真的没有办法吗,其实你可以去直接改 local storage)。我们会在下一篇文章中解决这个问题。


第三篇教程就到这里,简单回顾一下我们学到的知识点:

  • NzList 组件的使用,用 nz-menu“愚弄”ng-zorro
  • 进一步了解订阅模式的好处

下一篇文章,我们将会编写 Detail 详情组件。

我有问题

在写代码的时候遇到问题了吗?请按下面的顺序尝试解决:

  1. 看 console 中的报错
  2. 在 Angular 的文档中寻找线索
  3. 阅读我们提供的源码,看看哪里有不同
  4. Google,Stackoverflow
  5. 浏览 ng-zorro 的 issue,看是否有人遇到过同样的问题

原文链接:https://zhuanlan.zhihu.com/p/39255853

本站文章除注明转载外,均为本站原创或编译。欢迎任何形式的转载,但请务必注明出处。

转载请注明:文章转载自 JavaScript中文网 [https://www.javascriptcn.com]

本文地址:https://www.javascriptcn.com/read-35828.html

文章标题:Angular 实战教程 - 手把手教你构建待办事项应用 Today (Part 3)

相关文章
JavaScript教程:JS中的原型
Keith Peters 几年前发表的一篇博文,关于学习没有“new”的世界,其中解释了使用原型继承代替构造函数。两者都是纯粹的原型编码。 标准方法(The Standard Way) 一直以来,我们学习的在 JavaScript 里创建对...
2015-11-12
React Native v0.13.3 发布,Facebook开源框架
React is a JavaScript library for building user interfaces. Just the UI: Lots of people use React as the V in MVC. Since...
2015-11-12
必须记住的 30 类 CSS 选择器
开篇 有 30 个 CSS 选择器你必须烂熟于心,它们适应于当今各大主流浏览器。 1.* * { margin: 0; padding: 0; } *选择器选择的是每一个单一元素。很多程序员用上面的 CSS 将所有元素的 ma...
2015-11-16
2015年3月国内浏览器市场份额概括,chrome占32.97
本报告数据,来源于百度统计所覆盖的超过150万的站点,而不是baidu.com的流量数据。 注:奇虎360浏览器份额在2010年10月至2011年3月,和2012年9月以来,两次大幅下降,是因为360浏览器去掉了原本的浏览器特征(User...
2015-11-12
AngularJS vs. jQuery,看看谁更胜一筹
很多Web开发新手都会有这样的疑问“我应该使用什么开发框架呢,如何快速学会Web开发呢?”这个问题其实没有一个统一的正确答案,其中讨论最多的就是AngularJS和jQuery的差别。这两者的之间的比较很微妙,有时就像拿苹果和橘子在作比较,...
2015-11-12
7个让JavaScript变得更好的注意事项
随着浏览器性能提高,伴随着新的HTML5的编程接口的稳步采用,网页上的JavaScript的音量在逐渐增加。然而,一个写得不好的程序编码却拥有着打破整个网站,让用户为之沮丧和驱赶潜在客户的潜力。 开发人员必须使用所有供他们任意使用的工具和技...
2015-11-12
ASP.NET 2.0 AJAX应用程序设计
ASP.NET Aiax技术是一种实现异步(Asynchronous)网络应用的技术,它被整合在ASP.NET 2.0之中,是As P.NET的一种扩展技术。通过ASENETAjax技术,开发人员或程序员可以将Web服务器控件和客户端脚本结...
2015-11-14
纯JS实现旋转图片3D展示效果
CSS: &lt;style type=&quot;text&#x2F;css&quot;&gt; #show{position:relative;margin:20px auto;width:800px;} .item{position:...
2017-03-22
React Native 用JavaScript编写原生ios应用
ReactNative 可以基于目前大热的开源JavaScript库React.js来开发iOS和Android原生App。而且React Native已经用于生产环境——Facebook Groups iOS 应用就是基于它开发的。 Re...
2015-11-12
Html5 是否适合移动应用开发
HTML5最近这几年声誉鹊起,而基于HTML5技术的产品也风生水起。感觉现在你的产品要是不和HTML5沾点边,都不好意思和客户打招呼!移动应用开发中,HTML5更是不可或缺的角色,市面上不少移动应用中间件产品都号称支持HTML5,例如APP...
2015-11-12
回到顶部