MERN-技术栈高级教程-八-

58 阅读17分钟

MERN 技术栈高级教程(八)

原文:Pro MERN Stack

协议:CC BY-NC-SA 4.0

十三、高级功能

在这一章中,我们将看看许多应用共有的特性。这些特性跨越了 MERN 堆栈所包含的不同技术(前端、后端、数据库),并要求我们对所有这些技术进行整合以使其发挥作用。

我们将首先重构 UI 代码,以便在显示 Toast 消息的许多组件之间重用公共代码。我们将使用一种常见的模式来 React,将组件中大多数重复的代码移动到新文件中。然后,我们将实现到目前为止一直是占位符的报告页面。这就需要我们使用 MongoDB 的聚合函数。然后,我们将在问题列表页面中实现分页,以处理大型列表。这将锻炼 MongoDB 的另一个特性:find()的跳过和偏移选项。

然后,我们将在删除问题时实现一个撤销操作来恢复它们。最后,我们将显示一个搜索栏,用户可以在其中键入关键字并查找与关键字匹配的问题。

吐司的高阶分量

在显示和管理 Toast 消息的主视图中,有相当一部分代码是重复的。这包括以下内容:

  • 用于 Toast 的状态变量:显示状态、消息和消息类型

  • 方法showError()showSuccess()dismissToast()

  • Toast组件在render()功能中的位置

在许多其他语言中,这些问题可以通过从实现了这些方法和变量的基类继承实际视图来解决。但是,React 的作者建议在组件间重用代码时,组合优先于继承。因此,让我们创建一个新的组件来包装每个主视图,以添加 Toast 功能。我们姑且称这个类为ToastWrapper。因此,我们将使用ToastWrapper和任何视图组件,比如说IssueList,用组合一个包装组件。这是视图的父视图需要使用的,而不是普通视图组件。下面是ToastWrapperrender()方法的框架:

...
    render() {
      return (
        <React.Fragment>
          <IssueList />
          <Toast />
        </React.Fragment>
      );
    }
...

现在,我们可以将与 Toast 相关的所有状态变量移到ToastWrapper组件和dismissToast方法中。在 render 中,我们可以使用状态变量来控制 Toast 的显示,将代码移出IssueList

...
    constructor(props) {
      super(props);
      this.state = {
        toastVisible: false, toastMessage: “, toastType: 'success',
      };
      this.dismissToast = this.dismissToast.bind(this);
    }

    dismissToast() {
      this.setState({ toastVisible: false });
    }

    render() {
      const { toastType, toastVisible, toastMessage } = this.state;
      return (
        <React.Fragment>
          <IssueList />
          <Toast
            bsStyle={toastType}
            showing={toastVisible}
            onDismiss={this.dismissToast}
          >
            {toastMessage}
          </Toast>
        </React.Fragment>
      );
    }
...

在原始组件IssueList中,我们需要一种方法来显示错误,并且如果需要的话,消除它。让我们在ToastWrapper中创建showErrorshowSuccess方法,并将它们作为道具传递给IssueList。此外,让我们包括父母可能想要传递给IssueList的任何其他道具。

...
    showSuccess(message) {
      this.setState({ toastVisible: true, toastMessage: message, toastType: 'success' });
    }

    showError(message) {
      this.setState({ toastVisible: true, toastMessage: message, toastType: 'danger' });
    }
...
    render() {
      ...
          <IssueList
            showError={this.showError}
            showSuccess={this.showSuccess}
            dismissToast={this.dismissToast}
            {...this.props}
          />
...

我们仍然需要参数化视图,而不是硬编码IssueList。为每个需要显示 Toast 消息的视图创建这个类会违背重用代码的目的。一种方法是将原始组件作为父组件中包装的子组件进行传递,如下所示:

...
  <ToastWrapper>
    <IssueList .../>
  </ToastWrapper>
...

并且,在ToastWrapper类中,我们可以使用props.children来代替硬编码IssueList。对于其他视图,IssueEditIssueAddNavItem,我们将需要类似的包装组件。这在某些情况下可以工作,但是如果你看看这些组件在哪里被使用,你会注意到我们需要提供一个组件,而不是一个实例。下面是来自routes.js的片段。

...
  { path: '/issues/:id?', component: IssueList },
...

我们真正需要的是从现有的组件类IssueListIssueEditIssueAddNavItem中创建一个新的组件类*。让我们创建一个名为withToast的函数来做这件事,就像 React 路由的withRouter函数一样。它将接受原始组件作为参数,并返回一个使用ToastWrapper并包装原始组件的类。*

...

export default function withToast(OriginalComponent) {

  return class ToastWrapper extends React.Component {
    ...
    render() {
          <OriginalComponent
            ...
            {...this.props}
          />
  };
}
...

现在,无论哪里提到IssueList,我们都可以简单地用withToast(IssueList)来代替。这种从现有组件类创建新组件类并向其注入额外功能的模式被称为高阶组件 (HOC)。清单 13-1 显示了新 HOC 的完整代码。

import React from 'react';
import Toast from './Toast.jsx';

export default function withToast(OriginalComponent) {
  return class ToastWrapper extends React.Component {
    constructor(props) {
      super(props);
      this.state = {
        toastVisible: false, toastMessage: ", toastType: 'success',
      };
      this.showSuccess = this.showSuccess.bind(this);
      this.showError = this.showError.bind(this);
      this.dismissToast = this.dismissToast.bind(this);
    }

    showSuccess(message) {
      this.setState({ toastVisible: true, toastMessage: message, toastType: 'success' });
    }

    showError(message) {
      this.setState({ toastVisible: true, toastMessage: message, toastType: 'danger' });
    }

    dismissToast() {
      this.setState({ toastVisible: false });
    }

    render() {
      const { toastType, toastVisible, toastMessage } = this.state;
      return (
        <React.Fragment>
          <OriginalComponent
            showError={this.showError}
            showSuccess={this.showSuccess}
            dismissToast={this.dismissToast}
            {...this.props}
          />
          <Toast
            bsStyle={toastType}
            showing={toastVisible}
            onDismiss={this.dismissToast}
          >
            {toastMessage}
          </Toast>
        </React.Fragment>
      );
    }
  };
}

Listing 13-1ui/src/withToast.jsx: New HOC for Adding Toast Functionality to a Component

现在,要使用这个新功能,可以在routes.js中完成以下操作:

...
  { path: '/issues/:id?', component: withToast(IssueList) },
...

但是这增加了两个模块之间的耦合:routes.js现在需要知道哪些组件需要 Toast 功能。相反,就像我们对withRouter包装所做的那样,让我们将这个包装器封装在组件本身(如IssueList)中,并导出修改后的组件类,例如:

...
export default class IssueList extends React.Component {
 ...
}

export default withToast(IssueList);

...

但是这有一个副作用:它会隐藏静态方法fetchData(),这个方法是从这个组件外部调用的。我们还必须将组件的静态方法的引用复制到包装的组件中,使其可见。

...
const IssueListWithToast = withToast(IssueList);
IssueListWithToast.fetchData = IssueList.fetchData;

export default IssueListWithToast;
...

为了让IssueList组件使用withToast,需要做的其他更改是删除状态变量、Toast 相关的方法定义,以及用通过 props 接收的方法替换 Toast 函数的类方法。清单 13-2 中显示了IssueList组件的一整套变更。

...

import Toast from './Toast.jsx';

import withToast from './withToast.jsx';

...

export default class IssueList extends React.Component {
  ...
  constructor() {
    ...
    this.state = {
      ...
      toastVisible: false,
      toastMessage: ",
      toastType: 'info',
     };
    ...
    this.showSuccess = this.showSuccess.bind(this);
    this.showError = this.showError.bind(this);
    this.dismissToast = this.dismissToast.bind(this);
  }
...

  async loadData() {
    const { location: { search }, match, showError } = this.props;
    const data = await IssueList.fetchData(match, search, this.showError);
    ...
  }
...

  async closeIssue(index) {
    ...
    const { showError } = this.props;
    const data = await graphQLFetch(query, { id: issues[index].id },
      this.showError);
    ...
  }
...

  async deleteIssue(index) {
    ...
    const { showSuccess, showError } = this.props;
    ...
    const data = await graphQLFetch(query, { id }, this.showError);
    if (data && data.issueDelete) {
      ...
      this.showSuccess(`Deleted issue ${id} successfully.`);
    }
    ...
  }
...

  showSuccess(message) {
    ...
  }

  showError(message) {
    ...
  }

  dismissToast() {
    ...
  }

  render() {
    ...
    const { toastVisible, toastType, toastMessage } = this.state;
    ...
        <Toast
          ...
        >
          {toastMessage}
        </Toast>
    ...
  }
...

const IssueListWithToast = withToast(IssueList);

IssueListWithToast.fetchData = IssueList.fetchData;

export default IssueListWithToast;

...

Listing 13-2ui/src/IssueList.jsx: Changes for Using the withToast HOC

清单 13-3 中显示了对组件IssueEdit的一组类似更改。

...

import Toast from './Toast.jsx';

import withToast from './withToast.jsx';

...

export default class IssueEdit extends React.Component {
  ...
  constructor() {
    ...
    this.state = {
      ...
      toastVisible: false,
      toastMessage: ",
      toastType: 'info',
     };
    ...
    this.showSuccess = this.showSuccess.bind(this);
    this.showError = this.showError.bind(this);
    this.dismissToast = this.dismissToast.bind(this);
  }
...

  async handleSubmit(e) {
    ...
    const { showSuccess, showError } = this.props;
    const data = await graphQLFetch(query, { changes, id }, this.showError);
    if (data) {
      ...
      this.showSuccess('Updated issue successfully');
    }
  }

  async loadData() {
    const { match, showError } = this.props;
    const data = await IssueEdit.fetchData(match, null, this.showError);
    ...
  }

...

  showSuccess(message) {
    ...
  }

  showError(message) {
    ...
  }

  dismissToast() {
    ...
  }

  render() {
    ...
    const { toastVisible, toastType, toastMessage } = this.state;
    ...
        <Toast
          ...
        >
          {toastMessage}
        </Toast>
    ...
  }
...

const IssueEditWithToast = withToast(IssueEdit);

IssueEditWithToast.fetchData = IssueEdit.fetchData;

export default IssueEditWithToast;

...

Listing 13-3ui/src/IssueEdit.jsx: Changes for Using the withToast HOC

组件IssueAddNavItem的变化类似,只是略有不同。这个组件没有fetchData()方法,所以我们不需要将该方法复制到包装的组件中。此外,组件已经用withRouter()包装了,所以除此之外我们还需要添加withToast()包装器。变更如清单 13-4 所示。

...
...

import Toast from './Toast.jsx';

import withToast from './withToast.jsx';

...

class IssueAddNavItem extends React.Component {
  ...
  constructor() {
    ...
    this.state = {
      ...
      toastVisible: false,
      toastMessage: ",
      toastType: 'info',
     };
    ...
    this.showError = this.showError.bind(this);
    this.dismissToast = this.dismissToast.bind(this);
  }
...

  showError(message) {
    ...
  }

  dismissToast() {
    ...
  }
...

  async handleSubmit(e) {
    ...
    const { showError } = this.props;
    const data = await graphQLFetch(query, { issue }, this.showError);
    ...
  }
...

  render() {
    ...
    const { toastVisible, toastType, toastMessage } = this.state;
    ...
        <Toast
          ...
        >
          {toastMessage}
        </Toast>
    ...
  }
...

export default withToast(withRouter(IssueAddNavItem));
...

Listing 13-4ui/src/IssueAddNavItem.jsx: Changes for Using the withToast HOC

有了这些变化,应用应该继续像以前一样工作。您可以通过测试这些组件显示的每个错误或成功消息来测试它。

注意

尽管我们不遗余力地确保所有代码清单的准确性,但在本书付印之前,可能会有一些错别字甚至更正没有被收入书中。所以,一定要依赖 GitHub 库( https://github.com/vasansr/pro-mern-stack-2 )作为所有代码清单的经过测试和最新的源代码,尤其是如果有些东西不能像预期的那样工作。

MongoDB 聚合

到目前为止,我们已经在导航栏中为报告留下了占位符。为了准备在接下来的两个小节中实现这个页面,让我们探索一下 MongoDB 在获取集合的汇总数据方面提供了什么,也就是说,聚合

首先,让我们在数据库中创建许多问题,以便摘要看起来有意义。清单 13-5 显示了一个简单的 MongoDB shell 脚本,用于生成一组随机分布在日期、所有者和状态之间的问题。

/* global db print */
/* eslint no-restricted-globals: "off" */

const owners = ['Ravan', 'Eddie', 'Pieta', 'Parvati', 'Victor'];
const statuses = ['New', 'Assigned', 'Fixed', 'Closed'];

const initialCount = db.issues.count();

for (let i = 0; i < 100; i += 1) {
  const randomCreatedDate = (new Date())
    - Math.floor(Math.random() * 60) * 1000 * 60 * 60 * 24;
  const created = new Date(randomCreatedDate);
  const randomDueDate = (new Date())
    - Math.floor(Math.random() * 60) * 1000 * 60 * 60 * 24;
  const due = new Date(randomDueDate);

  const owner = owners[Math.floor(Math.random() * 5)];
  const status = statuses[Math.floor(Math.random() * 4)];
  const effort = Math.ceil(Math.random() * 20);
  const title = 'Lorem ipsum dolor sit amet, ${i}';
  const id = initialCount + i + 1;

  const issue = {
    id, title, created, due, owner, status, effort,
  };

  db.issues.insertOne(issue);
}

const count = db.issues.count();
db.counters.update({ _id: 'issues' }, { $set: { current: count } });

print('New issue count:', count);

Listing 13-5api/scripts/generate_data.mongo.js: Mongo Shell Script to Generate Some Data

让我们运行这个脚本一次,用 100 个新问题填充数据库。如果您在本地主机上使用 mongo,执行此操作的命令是:

$ cd api
$ mongo issuetracker scripts/generate_data.mongo.js

MongoDB 提供了集合方法aggregate()来使用*管道汇总和执行集合上的各种其他读取任务。*管道是在返回结果集之前对集合进行的一系列转换。事实上,对没有任何参数的aggregate()的默认调用与对find()的调用是相同的,也就是说,它返回集合中的整个文档列表,没有任何操作。

MongoDB 聚合管道由多个阶段组成。每个阶段都会在文档通过管道时对其进行转换。例如,一个match阶段将像一个过滤器一样过滤来自前一阶段的文档列表。为了模拟带有滤波器的find(),可以使用流水线中的单个match级。要转换文档,可以使用一个project阶段。与find()中的投影不同,它甚至可以使用表达式向文档添加新的计算字段。

每个阶段不必产生前一阶段的一对一映射。group阶段就是这样一个阶段,它产生一个摘要,而不是复制每个文档。unwind阶段的作用正好相反:它为每个数组元素将数组字段扩展到一个文档中。同一个阶段可以出现多次—例如,您可以从一个match开始,然后是一个group,然后是另一个match,以便在分组后过滤掉一些文档。

有关所有可用阶段的完整列表,请参考位于 https://docs.mongodb.com/manual/reference/operator/aggregation/ 的 MongoDB 管道阶段文档。我将只深入讨论在 Issue Tracker 应用中实现报告页面所需的两个阶段match(基于过滤器)和group(总结计数)。

aggregate()方法采用单个参数,即管道阶段规范的数组。每个阶段规范都是一个对象,带有一个指示阶段类型的键和保存阶段参数的值。正如您在find()方法中指定的那样,一个match阶段接收过滤器。因此,以下命令(在 MongoDB shell 中发出)将返回所有未解决的问题:

> db.issues.aggregate([ { $match: { status: 'New' } } ])

小组赛稍微复杂一点。它由一组需要创建的字段以及如何创建它们的规范组成。在对象规范中,键是字段的名称,值指定如何构造字段的值。这些值通常基于文档中的现有字段(实际上是前一阶段的结果),要引用这些字段,需要使用一个$前缀,如果没有这个前缀,它将被视为文字。

_id字段是强制性的,具有特殊的含义:它是结果分组所依据的值。通常,您使用一个现有的字段规范,将根据该字段的值进行分组。对于输出中的其余字段,您可以指定一个聚合函数来构造它们的值。

例如,如果您需要所有者分组的所有问题的总和和平均工作量,您可以使用下面的aggregate()命令(它在管道中有一个阶段):

> db.issues.aggregate([
  { $group: {
    _id: '$owner',
    total_effort: { $sum: '$effort' },
    average_effort: {$avg: '$effort' },
  } }
])

该命令将产生如下输出:

{ "_id" : "Victor", "total_effort" : 232, "average_effort" : 10.08695652173913 }
{ "_id" : "Pieta", "total_effort" : 292, "average_effort" : 12.166666666666666 }
{ "_id" : "Parvati", "total_effort" : 212, "average_effort" : 11.157894736842104 }
{ "_id" : "Eddie", "total_effort" : 143, "average_effort" : 8.9375 }
{ "_id" : "Ravan", "total_effort" : 213, "average_effort" : 10.65 }

如果整个集合需要分组为单个值,即不需要分组字段;您可以为_id使用一个文字值,通常为 null。此外,没有特殊的计数函数——您只需要将数字 1 相加,就可以得到所有匹配文档的计数。因此,使用aggregate()方法计算集合中文档数量的另一种方法是

> db.issues.aggregate([ { $group: { _id: null, count: { $sum: 1 } } }])

这将产生如下输出:

{ "_id" : null, "count" : 102 }

为了在对输出进行分组之前使用过滤器,可以使用一个match作为数组中的第一个元素,后面是group阶段。因此,要统计状态为New的问题的数量,可以使用下面的 aggregate 命令:

> db.issues.aggregate([
  { $match: { status: 'New' } },
  { $group: { _id: null, count: { $sum: 1 } } },
])

与集合中的所有问题相比,这将导致更低的计数,如下所示:

{ "_id" : null, "count" : 31 }

您可以通过发出一个count命令来验证计数是否正确。

> db.issues.count({ status: 'New' })

对于报告页面,让我们创建一个数据透视表(或交叉选项卡)输出,显示分配给不同所有者的问题数量,并进一步按状态分组。为此,我们需要两个分组字段,所有者和状态。通过将_id字段设置为对象而不是字符串,可以指定多个分组字段。该对象需要包含输出的名称和每个字段的字段标识符。然后,在输出中,将返回一个对象,而不是一个字符串作为_id的值,每个返回的行都有两个字段的不同组合。

因此,要获得按所有者和状态分组的问题计数,可以使用以下命令:

> db.issues.aggregate([
  { $group: {
    _id: { owner: '$owner',status: '$status' },
    count: { $sum: 1 },
  } }
])

这将产生一个文档数组,每个所有者状态组合对应一个文档,这是键或_id

{ "_id" : { "owner" : "Eddie", "status" : "Closed" }, "count" : 2 }
{ "_id" : { "owner" : "Parvati", "status" : "Closed" }, "count" : 2 }
{ "_id" : { "owner" : "Victor", "status" : "Closed" }, "count" : 6 }
{ "_id" : { "owner" : "Victor", "status" : "Assigned" }, "count" : 6 }
{ "_id" : { "owner" : "Parvati", "status" : "Fixed" }, "count" : 6 }
...

要在对结果进行分组之前添加过滤器,可以使用match阶段。例如,要获得工作量大于 4 的问题的计数,命令应该是:

> db.issues.aggregate([
  { $match: { effort: { $gte: 4 } } },
  { $group: {
    _id: { owner: '$owner',status: '$status' },
    count: { $sum: 1 },
  } }
])

这是查询的最终结构,我们将在下一节中使用它来实现有助于构建报告页面的 API。

发货计数 API

作为 Issue Counts API 实现的一部分,我们需要对 MongoDB 进行聚合查询,如前一节所述。按原样返回从 MongoDB 接收到的数据会非常方便,但是让我们尝试让调用者更容易使用它。在返回的数组中,每个所有者有一个元素,而不是每个所有者-状态组合有一个元素,每个状态的计数各有一个属性。在 GraphQL 模式中,让我们将其定义为一种新类型。

...
type IssueCounts {
  owner: String!
  New: Int
  Assigned: Int
  Fixed: Int
  Closed: Int
}
...

查询本身将接受一个过滤器规范作为输入(如在issueList查询中),并返回一组IssueCounts对象。清单 13-6 中显示了schema.graphql的适用变更。

...
type Issue {
  ...
}

type IssueCounts {

  owner: String!
  New: Int
  Assigned: Int
  Fixed: Int
  Closed: Int

}

...
type Query {
  ...
  issue(id: Int!): Issue!
  issueCounts(
    status: StatusType
    effortMin: Int
    effortMax: Int
  ): [IssueCounts!]!
}

...

Listing 13-6api/schema.graphql: Changes for Issue Counts API

我们将把 API 或解析器的实现和其他与这个对象相关的解析器放在issue.js中。我们将调用新函数counts(),它将接受一个过滤器规范,与list函数相同。让我们从list函数中复制过滤器构造部分,并在查询中使用它,这只不过是我们在上一节结束时最终确定的内容。然后,我们将处理数据库返回的每个文档,并更新一个名为stats的对象。我们将使用作为所有者的键来定位对象(如果没有找到,我们将创建一个),然后将状态键的值设置为计数。

...
  const stats = {};
  results.forEach((result) => {
    // eslint-disable-next-line no-underscore-dangle
    const { owner, status: statusKey } = result._id;
    if (!stats[owner]) stats[owner] = { owner };
    stats[owner][statusKey] = result.count;
  });
...

但是我们需要返回一个数组给调用者。我们可以通过简单地调用Object.values(stats)来做到这一点。最后,我们需要添加新函数作为另一个导出值,以及其他导出值。清单 13-7 中显示了对issue.js的全部更改。

...
async function remove(_, { id }) {
  ...
}

async function counts(_, { status, effortMin, effortMax }) {

  const db = getDb();
  const filter = {};

  if (status) filter.status = status;

  if (effortMin !== undefined || effortMax !== undefined) {
    filter.effort = {};
    if (effortMin !== undefined) filter.effort.$gte = effortMin;
    if (effortMax !== undefined) filter.effort.$lte = effortMax;
  }

  const results = await db.collection('issues').aggregate([
    { $match: filter },
    {
      $group: {
        _id: { owner: '$owner', status: '$status' },
        count: { $sum: 1 },
      },
    },
  ]).toArray();

  const stats = {};
  results.forEach((result) => {
    // eslint-disable-next-line no-underscore-dangle
    const { owner, status: statusKey } = result._id;
    if (!stats[owner]) stats[owner] = { owner };
    stats[owner][statusKey] = result.count;
  });
  return Object.values(stats);

}

module.exports = {
  list,
  add,
  get,
  update,
  delete: remove,
  counts,
};
...

Listing 13-7api/issue.js: Changes for Issue Counts API

现在,为了将新函数绑定到 GraphQL 模式,让我们对为端点issueCounts指定解析器的api_handler.js进行更改。清单 13-8 中显示了适当的变更。

...
const resolvers = {
  Query: {
    ...
    issueCounts: issue.counts,
  },
  ...
};
...

Listing 13-8api/api_handler.js: Changes for Issue Counts API

您现在可以使用操场测试 API。一个简单的带有最小工作量过滤器的查询如下所示:

query {
  issueCounts(effortMin: 4) {
    owner New Assigned Fixed Closed
  }
}

运行该查询后,您应该会看到如下结果:

{
  "data": {
    "issueCounts": [
      {
        "owner": "Eddie",
        "New": 4,
        "Assigned": 2,
        "Fixed": 4,
        "Closed": 1
      },
      {
        "owner": "Parvati",
        "New": 4,
        "Assigned": 3,
        "Fixed": 6,
        "Closed": 2
      },
      ...
    ]
  }
}

练习:发料数量 API

  1. 除了将stats对象转换成一个数组,我们还能原样返回stats对象吗?模式会是什么样子?提示:在 https://github.com/apollographql/apollo/issues/5 查找 Apollo GraphQL 问题。

本章末尾有答案。

报告页面

现在我们有了一个工作的 API,让我们为报告页面构建 UI。我们将使用一种通常称为交叉表或数据透视表的格式:一个表的一个轴标有状态,另一个轴标有所有者。

由于所有者的数量可能很多,而状态的数量有限,因此让我们在水平轴(表格行标题)上排列状态,并使用每个所有者一行来显示分配给该所有者的问题数。通过这种方式,我们可以轻松处理大量的所有者。让我们用从React.component继承的常规组件替换IssueReport中的无状态组件占位符,并从render()方法开始。我们将使用一个可折叠的面板并将IssueFilter组件放在这里,就像我们对问题列表所做的那样。接下来,让我们展示一个带有报告的表格,API 返回的数据中的每个值都占一行。

但是我们不能原样使用IssueFilter组件。这是因为它的 Apply 按钮被硬编码为导航到路线/issues,过滤器被设置为搜索字符串。让我们首先解决这个问题,将基本 URL 作为道具传递给这个组件。在问题列表中,这可以作为/issues传递,在报告页面中,这可以作为/report传递。在清单 13-9 中显示了IssueFilter组件的变化。

...
  applyFilter() {
    ...
    const { history, urlBase } = this.props;
    ...
    history.push({ pathname: '/issues' urlBase, search });
  }
...

Listing 13-9ui/src/IssueFilter.jsx: Changes for Customizable Base URL

让我们也为传入新属性而对IssueList进行修改。这一变化如清单 13-10 所示。

...
  render() {
    ...
          <Panel.Body collapsible>
            <IssueFilter urlBase="/issues" />
          </Panel.Body>
    ...
  }
}
...

Listing 13-10ui/src/IssueList.jsx: Changes to Customizable Base URL in IssueFilter

现在,让我们从IssueReport组件中的 render()方法开始,使用一个可折叠的过滤器和一个表格。我们将很快填充headerColumnsstatRows变量。

...
    return (
      <>
        <Panel>
          <Panel.Heading>
            <Panel.Title toggle>Filter</Panel.Title>
          </Panel.Heading>
          <Panel.Body collapsible>
            <IssueFilter urlBase="/report" />
          </Panel.Body>
        </Panel>
        <Table bordered condensed hover responsive>
          <thead>
            <tr>
              <th />
              {headerColumns}
            </tr>
          </thead>
          <tbody>
            {statRows}
          </tbody>
        </Table>
      </>
    );
...

注意

语法<><React.Fragment>的 JSX 快捷方式。

在为每个状态生成标题列时,让我们创建一个可以迭代的所有状态的数组,而不是单独指定每个状态。使用这个,我们将为标题中的每个状态生成一个<th>

...
const statuses = ['New', 'Assigned', 'Fixed', 'Closed'];
...

  render() {
    const headerColumns = (
      statuses.map(status => (
        <th key={status}>{status}</th>
      ))
    );
    ...
  }
...

至于行本身,我们需要通过调用 API 来迭代接收到的数据。让我们将这些数据存储在一个名为stats的状态变量中。如果这个变量没有初始化(当异步 API 调用还没有返回时就会出现这种情况),我们返回一个空白页。现在可以生成这样的行:

...
  render() {
    const { stats } = this.state;
    if (stats == null) return null;
    ...

    const statRows = stats.map(counts => (
      <tr key={counts.owner}>
        <td>{counts.owner}</td>
        {statuses.map(status => (
          <td key={status}>{counts[status]}</td>
        ))}
      </tr>
    ));
    ...
  }

...

现在让我们实现数据获取静态方法fetchData()。从过滤器创建查询变量的初始部分可以从IssueList.jsx复制过来,除了hasSelectionselectedID变量。GraphQL 的查询是我们在操场上测试时使用的,但是使用过滤器参数作为变量。

...
  static async fetchData(match, search, showError) {
    const params = new URLSearchParams(search);
    const vars = { };
    ...
    if (!Number.isNaN(effortMax)) vars.effortMax = effortMax;

    const query = `query issueList(
      $status: StatusType
      $effortMin: Int
      $effortMax: Int
    ) {
      issueCounts(
        status: $status
        effortMin: $effortMin
        effortMax: $effortMax
      ) {
        owner New Assigned Fixed Closed
      }
    }`;
    const data = await graphQLFetch(query, vars, showError);
    return data;
  }
...

对于该组件的其余实现,让我们遵循与其他主视图中相同的模式。我们需要添加以下内容:

  • 从存储区获取初始数据并在消费后将其删除的构造函数

  • 一种componentDidMount()方法,用于在数据尚未加载的情况下加载数据

  • 一个componentDidUpdate()方法,用于检查搜索字符串是否已经更改,如果已经更改,则重新加载数据

  • 这两个生命周期方法可以调用的一个loadData()方法来加载它并设置状态

  • 一个 Toast 包装器,它需要被导出,而不是原来的类

页面的最终完整代码如清单 13-11 所示。

import React from 'react';
import { Panel, Table } from 'react-bootstrap';

import IssueFilter from './IssueFilter.jsx';
import withToast from './withToast.jsx';
import graphQLFetch from './graphQLFetch.js';
import store from './store.js';

const statuses = ['New', 'Assigned', 'Fixed', 'Closed'];

class IssueReport extends React.Component {
  static async fetchData(match, search, showError) {
    const params = new URLSearchParams(search);
    const vars = { };
    if (params.get('status')) vars.status = params.get('status');

    const effortMin = parseInt(params.get('effortMin'), 10);
    if (!Number.isNaN(effortMin)) vars.effortMin = effortMin;
    const effortMax = parseInt(params.get('effortMax'), 10);
    if (!Number.isNaN(effortMax)) vars.effortMax = effortMax;

    const query = `query issueList(
      $status: StatusType
      $effortMin: Int
      $effortMax: Int
    ) {
      issueCounts(
        status: $status
        effortMin: $effortMin
        effortMax: $effortMax
      ) {
        owner New Assigned Fixed Closed
      }
    }`;
    const data = await graphQLFetch(query, vars, showError);
    return data;
  }

  constructor(props) {
    super(props);
    const stats = store.initialData ? store.initialData.issueCounts : null;
    delete store.initialData;
    this.state = { stats };
  }

  componentDidMount() {
    const { stats } = this.state;
    if (stats == null) this.loadData();
  }

  componentDidUpdate(prevProps) {
    const { location: { search: prevSearch } } = prevProps;
    const { location: { search } } = this.props;
    if (prevSearch !== search) {
      this.loadData();
    }
  }

  async loadData() {
    const { location: { search }, match, showError } = this.props;
    const data = await IssueReport.fetchData(match, search, showError);
    if (data) {
      this.setState({ stats: data.issueCounts });
    }
  }

  render() {
    const { stats } = this.state;
    if (stats == null) return null;

    const headerColumns = (
      statuses.map(status => (
        <th key={status}>{status}</th>
      ))
    );

    const statRows = stats.map(counts => (
      <tr key={counts.owner}>
        <td>{counts.owner}</td>
        {statuses.map(status => (
          <td key={status}>{counts[status]}</td>
        ))}
      </tr>
    ));

    return (
      <>
        <Panel>
          <Panel.Heading>
            <Panel.Title toggle>Filter</Panel.Title>
          </Panel.Heading>
          <Panel.Body collapsible>
            <IssueFilter urlBase="/report" />
          </Panel.Body>
        </Panel>
        <Table bordered condensed hover responsive>
          <thead>
            <tr>
              <th />
              {headerColumns}
            </tr>
          </thead>
          <tbody>
            {statRows}
          </tbody>
        </Table>
      </>
    );
  }
}

const IssueReportWithToast = withToast(IssueReport);
IssueReportWithToast.fetchData = IssueReport.fetchData;

export default IssueReportWithToast;

Listing 13-11ui/src/IssueReport.jsx: New Report Page

如果您现在测试报告页面,您应该会看到类似于图 13-1 中的页面。您可以使用可折叠的滤镜面板来更改滤镜并查看其效果。

img/426054_2_En_13_Chapter/426054_2_En_13_Fig1_HTML.jpg

图 13-1

报告页面

练习:报告页面

  1. 假设您需要行总计。你将如何着手实现这一点?试试看。

  2. 列合计怎么样?如何实现这些目标?

本章末尾有答案。

带分页的列表 API

您可能已经注意到问题列表页面已经变得很难使用,因为它显示了数据库中的所有问题。在这一节和下一节中,我们将实现分页,以便向用户显示一组有限的问题,并可以导航到其他页面。让我们为下一节保留 UI,并在这一节修改 List API 以支持分页。

为了显示分页栏,我们还需要列表的总页数。因此,让我们首先修改模式,在问题列表之外添加页面计数。我们不直接返回问题列表,而是需要返回一个包含列表和页数的对象。然后,除了过滤器规范之外,我们还需要指定要获取哪个页面。

清单 13-12 显示了模式中的变化。

...
type IssueCounts {
  ...
}

type IssueListWithPages {

  issues: [Issue!]!
  pages: Int

}

...

type Query {
  ...
  issueList(
    ...
    page: Int = 1
  ): [Issue!]! IssueListWithPages
  ...
}
...

Listing 13-12api/schema.graphql: Addition of Page Count to List API

注意

在实际项目中更改 GraphQL API 并不是一个好的做法,因为这会破坏 UI 应用。推荐的做法是创建一个新的 API,例如,为此创建一个名为issueListWithPages的查询,尤其是在应用已经投入生产的情况下。但是我正在修改现有的 API,以便最终的代码简洁。

在 API 实现中,我们必须使用新的参数page跳到给定的页面,并限制返回的对象数量。MongoDB 游标方法skip()可以用来获取从一个偏移量开始的文档列表。此外,limit()光标方法可用于将输出限制到某个数字。我们将使用PAGE_SIZE常量来表示一页中的文档数。

...
const PAGE_SIZE = 10;
...
  const issues = await db.collection('issues').find(filter).
    .skip(PAGE_SIZE * (page - 1))
    .limit(PAGE_SIZE)
    .toArray();
...

每当我们在列表中使用偏移量时,我们还需要确保列表在多次查询时处于相同的顺序。如果没有明确的排序顺序,MongoDB 不能保证输出的任何顺序。两个查询之间文档的顺序可能不同(尽管看起来总是插入的顺序)。为了保证一定的顺序,我们需要包含一个排序规范。由于 ID 是排序的自然关键字(因为它匹配插入顺序),并且它是一个索引字段(也就是说,请求列表按这个顺序排序没有损失),所以让我们将它用作排序关键字。

...
  const issues = await db.collection('issues').find(filter).
    .sort({ id: 1 })
    ...
...

现在,我们还需要页面数,这需要与该过滤器匹配的文档数。MongoDB 让我们查询游标本身匹配的文档数,而不是通过另一个查询来获得计数。所以,与其把find()返回的光标转换成数组,不如先保留光标并查询它的计数,再转换成数组返回。

...
  const cursor = db.collection('issues').find(filter)
    ....

  const totalCount = await cursor.count(false);
  const issues = cursor.toArray();
  const pages = Math.ceil(totalCount / PAGE_SIZE);
...

注意,count()方法是异步的,但是它最终会评估光标的内容。因此,可以同步调用游标toArray()上的下一个调用。count()函数的参数接受一个布尔值,该值决定返回的计数是否考虑了skip()limit()的影响。使用参数的值作为false给出了匹配过滤器的对象总数。这是我们需要的计数。

现在,我们可以返回问题列表以及返回值中的页数。清单 13-13 显示了所有的变化,包括 API 实现中的变化。

...

const PAGE_SIZE = 10;

async function list(_, {
  status, effortMin, effortMax, page,
}) {
  ...
  const issues = await db.collection('issues').find(filter).toArray();
  const cursor = db.collection('issues').find(filter)
    .sort({ id: 1 })
    .skip(PAGE_SIZE * (page - 1))
    .limit(PAGE_SIZE);

  const totalCount = await cursor.count(false);
  const issues = cursor.toArray();
  const pages = Math.ceil(totalCount / PAGE_SIZE);
  return issues;
  return { issues, pages };
}
...

Listing 13-13api/issue.js: Add Pagination Support to List API

由于模式中的返回值已经改变,我们还需要改变调用者,即IssueList组件来适应这种改变。我们不需要直接使用数据中的值issueList,而是需要将它用作issueList.issues。我们还不会实现分页;此更改只是为了通过显示前 10 个问题来确保问题列表页面继续工作。

清单 13-14 显示了对该组件的更改。

...
  static async fetchData(match, search, showError) {
    ...
    const query = `query issueList(
      ...
      issueList(
        ...
      ) {
        issues {
          id title status owner
          created effort due
        }
      }
      ...
  }
...

  constructor() {
    super();
    const issues = store.initialData
      ? store.initialData.issueList.issues : null;
    ...
  }
...

  async loadData() {
    ...
    if (data) {
      this.setState({
        issues: data.issueList.issues,
        ...
      });
    }
  }
...

Listing 13-14ui/src/IssueList.jsx: Changes to Account for API Change

API 的变化可以在操场上测试。您可以使用以下查询来测试页面参数:

query {
  issueList(page: 4) {
     issues { id title }
     pages
  }
}

这将返回 10 期和总页数。如果您有 102 期(最初的两期来自init.mongo.js,另外 100 期来自generate_data.mongo.js),则页数应该返回为 11,如下所示:

{
  "data": {
    "issueList": {
      "issues": [
        {
          "id": 31,
          "title": "Lorem ipsum dolor sit amet, 28"
        },
        ...
        {
          "id": 40,
          "title": "Lorem ipsum dolor sit amet, 37"
        }

      ],
      "pages": 11
    }
  }
}

此外,您可以测试问题列表页面,以确保 API 更改没有破坏任何东西,只是您现在应该只看到第一页(即 10 个问题),而不是所有 100 个左右的问题。

UI 页面

现在让我们使用我们在上一节中编写的新 API 来显示一个页面栏。

React-Bootstrap 支持分页表示,但这是一个纯粹的表示组件。为了计算要显示的页面,特别是如果有页面的页面(让我们称之为部分),要完成的计算不是任何开箱即用组件的一部分。根据 https://react-bootstrap.github.io/components/pagination/#pagination 的文档,以前的版本支持这样一个组件,它现在作为@react-bootstrap/pagination在一个单独的存储库中。但是我没有选择这个库,原因如下:

  • 国家预防机制网页说它没有得到积极维护。

  • 产生的页面项目是按钮而不是链接,这使得搜索引擎机器人很难索引它们。我们更喜欢的是能够与 React 路由配合使用的<Link>或同等产品。

因此,让我们创建自己的极简分页栏,以五页为单位显示页面。要进入下一个或上一个部分,让我们使用该栏两端的><指示器。这所需的数学很简单,足以证明可以做什么。

首先,让我们修改IssueList组件中的数据获取器,以包括查询中的总页数,并将其保存在状态中。

...
  static async fetchData(match, search, showError) {
    const params = new URLSearchParams(search);
    ...

    let page = parseInt(params.get('page'), 10);
    if (Number.isNaN(page)) page = 1;
    vars.page = page;

    const query = `query issueList(
      ...
      $page: Int
    ) {
      issueList(
        ...
        page: $page
      ) {
        issues {
          ...
        }
        pages
      }
      ...
    }`;
  }
...

现在,data.issueList.issues将有一个问题列表,data.issueList.pages将有这个问题的总页数。让我们使用所有这些来设置状态变量,这些变量将方便地呈现分页栏。我们需要在构造函数和初始化或修改状态的loadData()方法中这样做。

...
  constructor() {
    super();
    const issues = store.initialData
      ? store.initialData.issueList.issues : null;
    const selectedIssue = store.initialData
      ? store.initialData.issue
      : null;
    const initialData = store.initialData || { issueList: {} };
    const {
      issueList: { issues, pages }, issue: selectedIssue,
    } = initialData;
    ...
    this.state = {
      ...
      pages,
    };
    ...
  }
...
  async loadData() {
    ...
    if (data) {
      this.setState({
        ...
        pages: data.issueList.pages,
      });
    }
  }
...

现在,在render()函数中,我们可以开始布局分页栏。但是,除了使用一个LinkContainer来创建实际的链接之外,bar 中的每个链接还需要对当前活动的过滤器进行编码。为了缓解这个问题,让我们在这个文件中创建一个名为PageLink的新组件。这将根据传递的搜索params、要链接到的页码和当前页面,用链接包装作为显示对象传递的任何内容,以确定该链接是否需要突出显示为活动的。我们将使用页码 0 来表示不可用的页面。

...
function PageLink({
  params, page, activePage, children,
}) {
  params.set('page', page);
  if (page === 0) return React.cloneElement(children, { disabled: true });
  return (
    <LinkContainer
      isActive={() => page === activePage}
      to={{ search: `?${params.toString()}` }}
    >
      {children}
    </LinkContainer>
  );
}
...

现在,在render()函数中,我们可以生成一系列页面链接。我们将使用的实际链接组件是 React-Bootstrap 的Pagination.Item组件。我不会解释下面使用的数学细节,但总体逻辑是页面被分成每个SECTION_SIZE页面的部分,我们需要做的就是显示该部分中的页面。<>指示器将移动到上一节和下一节,这只是该节起始页之前和之后的SECTION_SIZE页。如果 previous 和 next 不可用,小于第一页或大于最后一页,我们将指定 0 作为要导航到的页面,这样它将被禁用。

...
    const params = new URLSearchParams(search);
    let page = parseInt(params.get('page'), 10);
    if (Number.isNaN(page)) page = 1;

    const startPage = Math.floor((page - 1) / SECTION_SIZE) * SECTION_SIZE + 1;
    const endPage = startPage + SECTION_SIZE - 1;
    const prevSection = startPage === 1 ? 0 : startPage - SECTION_SIZE;
    const nextSection = endPage >= pages ? 0 : startPage + SECTION_SIZE;

    const items = [];
    for (let i = startPage; i <= Math.min(endPage, pages); i += 1) {
      params.set('page', i);
      items.push((
        <PageLink key={i} params={params} activePage={page} page={i}>
          <Pagination.Item>{i}</Pagination.Item>
        </PageLink>
      ));
    }
...

最后,让我们在返回的 JSX 中显示这组项目,作为分页栏的一部分。为了启动这个工具条,我们使用了Pagination组件,并将每个可点击的链接显示为这个组件中的一个Pagination.Item组件,由一个PageLink包装。清单 13-15 显示了完整的更改,包括在IssueList组件中显示分页的最后一个更改。

...

import { Panel, Pagination } from 'react-bootstrap';

import { LinkContainer } from 'react-router-bootstrap';

...

const SECTION_SIZE = 5;

function PageLink({

  params, page, activePage, children,

}) {

  params.set('page', page);
  if (page === 0) return React.cloneElement(children, { disabled: true });
  return (
    <LinkContainer
      isActive={() => page === activePage}
      to={{ search: `?${params.toString()}` }}
    >
      {children}
    </LinkContainer>
  );

}

class IssueList extends React.Component {
  static async fetchData(match, search, showError) {
    const params = new URLSearchParams(search);
    ...

    let page = parseInt(params.get('page'), 10);
    if (Number.isNaN(page)) page = 1;
    vars.page = page;

    const query = `query issueList(
      ...
      $page: Int
    ) {
      issueList(
        ...
        page: $page
      ) {
        issues {
          ...
        }
        pages
      }
      ...
    }`;
  }

  constructor() {
    super();
    const issues = store.initialData
      ? store.initialData.issueList.issues : null;
    const selectedIssue = store.initialData
      ? store.initialData.issue
      : null;
    const initialData = store.initialData || { issueList: {} };
    const {
      issueList: { issues, pages }, issue: selectedIssue,
    } = initialData;
    ...
    this.state = {
      ...
      pages,
    };
    ...
  }
...
  async loadData() {
    ...
    if (data) {
      this.setState({
        ...
        pages: data.issueList.pages,
      });
    }
  }
...

  render() {
    ...

    const { selectedIssue, pages } = this.state;
    const { location: { search } } = this.props;

    const params = new URLSearchParams(search);
    let page = parseInt(params.get('page'), 10);
    if (Number.isNaN(page)) page = 1;

    const startPage = Math.floor((page - 1) / SECTION_SIZE) * SECTION_SIZE + 1;
    const endPage = startPage + SECTION_SIZE - 1;
    const prevSection = startPage === 1 ? 0 : startPage - SECTION_SIZE;
    const nextSection = endPage >= pages ? 0 : startPage + SECTION_SIZE;

    const items = [];
    for (let i = startPage; i <= Math.min(endPage, pages); i += 1) {
      params.set('page', i);
      items.push((
        <PageLink key={i} params={params} activePage={page} page={i}>
          <Pagination.Item>{i}</Pagination.Item>
        </PageLink>
      ));
    }

    return (
      <React.Fragment>
        ...
        <Pagination>
          <PageLink params={params} page={prevSection}>
            <Pagination.Item>{'<'}</Pagination.Item>
          </PageLink>
          {items}
          <PageLink params={params} page={nextSection}>
            <Pagination.Item>{'>'}</Pagination.Item>
          </PageLink>
        </Pagination>
      </React.Fragment>
    );
  }

...

Listing 13-15ui/src/IssueList.jsx: Changes for Pagination UI

如果您现在测试应用,您会发现主页重定向到完整的问题列表,该列表有 11 页,分为三个部分,每个部分 5 页。<>链接将分别将您带到上一个和下一个部分,如果由于达到部分边界而导致操作不可用,它们将显示为禁用。带有分页栏的问题列表页面截图如图 13-2 所示。

img/426054_2_En_13_Chapter/426054_2_En_13_Fig2_HTML.jpg

图 13-2

第 8 页上带有分页栏的问题列表屏幕

但是还有一个小问题:分页,尤其是活动按钮,有一个 z 索引为 3 的样式。这是由 Bootstrap 设置的。这本身没问题,但是当显示 Toast 消息时,分页按钮隐藏了消息。为了克服这个问题,让我们将 Toast 消息的 z-index 设置得更高一些,这样它总是出现在顶部。清单 13-16 中显示了Toast.jsx的变更。

...
      <Collapse in={showing}>
        <div style={{
          position: 'fixed', bottom: 20, left: 20, zIndex: 10,
        }}
        >
...

Listing 13-16ui/src/Toast.jsx: Set the Toast’s Z-Index So It Always Shows on Top

可以基于当前页面创建更直观、更有创意的分页,这也让用户可以转到页面的末尾或开头。但是这应该已经为您提供了如何在 MERN 堆栈中实现分页的基本构件。

分页性能

使用同一个游标来获取计数的方法对于小数据集来说是可以的,但是不能用于大数据集。知道页数的分页的问题是,它需要过滤集中的文档总数。

事实是,在任何数据库中,计算匹配的数量都是很昂贵的。唯一的方法是应用过滤器并访问每个文档来检查它是否与过滤器匹配。当然,除非您对每个可能的过滤器组合都有索引,这意味着要么限制您希望允许用户使用的过滤器类型,要么花费巨大的存储容量来索引所有组合。

我发现,实际上,当结果可能非常大时,显示精确的页数或匹配记录数没有多大用处。如果它确实有数百页长,那么用户很可能不想直接跳到第 97 页,甚至最后一页。在这种情况下,建议只显示上一个和下一个链接,而不是查询每个请求的总数。React-Bootstrap 的分页组件将很好地适用于这种方法。

如果用户(或搜索引擎机器人)不太可能超出最初的几个页面,这种方法肯定会奏效。但是碰巧的是,即使是一个skip()操作也必须遍历所有被跳过的文档,才能到达被显示页面的开头。例如,如果有一百万个文档,并且用户(或搜索引擎机器人)将一直遍历到最后一页,这将意味着最后一页将在返回对应于最后一页的列表之前检索所有一百万个文档。

使用这些大型集合的理想策略是在 API 的返回中返回一个值,以索引字段值的形式指示下一页的开始位置。在问题跟踪器应用的情况下,ID 字段非常适合。使用这种策略,你不会使用skip()操作;相反,您使用 ID 作为过滤器,使用$gte操作符。由于 ID 字段被索引,数据库就不必跳过这么多文档来得到这个 ID;它将直接到达文档并从那里遍历以获取一页文档。在这些情况下,禁用“上一页”和“下一页”按钮变得很重要,超出了本书的范围。

练习:分页用户界面

  1. 当前活动页面必须从search字符串计算,一次在fetchData()方法中,另一次在render()方法中。在这种情况下,这可能是一个简单的操作,但在这可能需要大量代码和/或计算量很大的情况下,您是否应该考虑在状态中保存这个值?有哪些利弊?提示:在 https://reactjs.org/docs/thinking-in-react.html#step-3-identify-the-minimal-but-complete-representation-of-ui-state 查阅“React 中思考”页面。

本章末尾有答案。

撤消删除 API

我们要实现的下一个特性是删除操作的撤销操作。破坏性操作(如删除)的旧惯例是要求用户确认。但是如果我们不要求确认,可用性就会增强,因为用户很少会对“你确定吗?”回答“不”问题。如果用户错误地删除了问题,提供撤销功能会更好。

在下一节中,我们将实现撤销功能的用户界面来删除问题。首先,在本节中,我们将实现为此所需的 API,一个恢复已删除问题的 API。我们需要做的第一个改变是在模式中。为此,我们将添加一个新的突变,并将其命名为issueRestore。清单 13-17 显示了对模式的更改。

...
type Mutation {
  ...
  issueRestore(id: Int!): Boolean!
}
...

Listing 13-17api/schema.graphql: Changes to the Restore API

接下来,API 的实际实现有点类似于 Delete API 本身。不同之处在于,restore API 不是从issues集合转移到deleted_issues集合,而是以相反的方向转移一个问题:从deleted_issues集合转移到issues集合。让我们从remove()函数中复制代码,并交换这两个集合名。

清单 13-18 中列出了增加restore功能的变更。

...
async function remove(_, { id }) {
  ...
}

async function restore(_, { id }) {

  const db = getDb();
  const issue = await db.collection('deleted_issues').findOne({ id });
  if (!issue) return false;
  issue.deleted = new Date();

  let result = await db.collection('issues').insertOne(issue);
  if (result.insertedId) {
    result = await db.collection('deleted_issues').removeOne({ id });
    return result.deletedCount === 1;
  }
  return false;

}

...

module.exports = {
  ...
  restore,
  counts,
};
...

Listing 13-18api/issue.js: New Restore API Implementation

最后,我们必须将解析器绑定到 API 处理程序中的 API 端点。这一变化如清单 13-19 所示。

...
const resolvers = {
  ...
  Mutation: {
    ...
    issueRestore: issue.restore,
  },
  ...
};
...

Listing 13-19api/api_handler.js: Changes to Restore API

您可以使用操场测试新的 API。您可以使用“问题列表”页面中的“删除”按钮删除问题。然后,在 Playground 中,您可以执行以下变化,用您刚刚删除的问题的 ID 替换 ID:

mutation {
  issueRestore(id: 6)
}

它应该会返回一个成功值,如果您刷新浏览器,您应该会看到已删除的问题已被恢复。

撤消删除用户界面

启动撤消删除操作的最佳位置是显示问题已被删除的 Toast 消息中。在指示删除操作成功的 Toast 消息中,让我们包含一个按钮,单击它可以启动撤销。这需要在IssueList组件中完成。

然后,当点击按钮时,我们需要调用 Restore API。最好的方法是使用IssueList类中的一个方法来恢复一个问题,这个方法接受要恢复的问题的 ID。撤销按钮现在可以将其onClick属性设置为这个方法。对此的更改如清单 13-20 所示。

...
import { Panel, Pagination, Button } from 'react-bootstrap';
...

async deleteIssue(index) {
    ...
      showSuccess('Deleted issue ${id} successfully.');
      const undoMessage = (
        <span>
          {`Deleted issue ${id} successfully.`}
          <Button bsStyle="link" onClick={() => this.restoreIssue(id)}>
            UNDO
          </Button>
        </span>
      );
      showSuccess(undoMessage);
    ...
  }

  async restoreIssue(id) {
    const query = `mutation issueRestore($id: Int!) {
      issueRestore(id: $id)
    }`;
    const { showSuccess, showError } = this.props;
    const data = await graphQLFetch(query, { id }, showError);
    if (data) {
      showSuccess(`Issue ${id} restored successfully.`);
      this.loadData();
    }
  }
...

Listing 13-20ui/src/IssueList.jsx: Changes for Including an Undo Button

现在,当您单击问题列表中任何一行上的删除按钮时,Toast 消息将包含一个撤销按钮(实际上是一个链接)。点击该按钮将会恢复已删除的问题。图 13-3 显示了问题列表页面的屏幕截图,其中包含带有撤销链接的 Toast 消息。您应该能够单击撤销链接,并看到已删除的问题又回到了问题列表中。

img/426054_2_En_13_Chapter/426054_2_En_13_Fig3_HTML.jpg

图 13-3

带有撤消链接的删除成功提示

文本索引 API

大多数应用中的搜索栏让你只需输入一些单词就能找到文档。我们不会像搜索过滤器那样实现它,而是作为一个自动完成功能来实现,它会找到所有与输入的单词匹配的问题,并让用户选择其中一个来直接查看。我们将在导航栏中添加这个搜索,因为用户应该能够跳转到一个特定的问题,无论他们正在查看哪个页面。

假设问题的数量很大,如果我们对所有问题都应用像正则表达式这样的过滤标准,效果不会很好。这是因为要应用正则表达式,MongoDB 必须扫描所有文档并应用正则表达式来查看它是否与搜索词匹配。

另一方面,MongoDB 的文本索引可以让您快速找到包含特定术语的所有文档。文本索引收集所有文档中的所有术语(单词),并创建一个查找表,给定一个术语(单词),返回包含该术语(单词)的所有文档。您可以通过以下 MongoDB shell 命令使用标题中的所有单词创建这样一个索引:

> db.issues.createIndex({ title: "text" })

现在,如果您查找标题中任何术语的问题,它将返回匹配的文档。使用文本索引的语法如下:

> db.issues.find({ $text: {$search: "click" } })

这将返回一个标题中包含单词click的文档。但这可能还不够,我们可能还需要在描述中搜索相同术语的能力。因此,让我们通过在索引中包含描述文本来重新创建索引。要删除创建的索引,让我们首先确定当前存在哪些索引。

> db.issues.getIndexes()

您应该找到一个属性设置为title_text的索引。我们需要删除该索引,并重新创建包含描述字段的索引。

> db.issues.dropIndex('title_text')
> db.issues.createIndex({ title: "text", description: "text" })

如果您现在执行相同的find()查询来查找包含单词click的文档,您应该会发现它返回两个问题,第一个问题是因为标题中有术语“clicking”。第二个也将被返回,因为描述中有“点击”一词。请注意,这不是模式搜索,例如,搜索术语“clic”不会匹配任何文档,即使它与文档中的文本部分匹配。此外,你会发现常见的词(称为停用词),如“在”、“当”等。没有索引,搜索这些将导致没有匹配。

让我们将这个索引保存在init.mongo.js中,这样下次数据库初始化时,就会创建这个索引。变更如清单 13-21 所示。

...
db.issues.createIndex({ created: 1 });

db.issues.createIndex({ title: 'text', description: 'text' });

...

Listing 13-21api/scripts/init.mongo.js: Addition of Text index

下一个变化是在 GraphQL 模式中。让我们为搜索字符串再添加一个过滤器选项。清单 13-22 中显示了这些变化。

...
type Query {
  ...
  issueList(
    ...
    search: String
    page: Int = 1
  ): IssueListWithPages
  ...
}
...

Listing 13-22api/schema.graphql: Addition of Search in Filter

现在,让我们更改issue.js中的list解析器,使用新的参数来搜索文档。所有需要做的就是添加搜索字符串作为一个额外的过滤器,如果它存在的话。变更如清单 13-23 所示。

...
async function list(_, {
  status, effortMin, effortMax, search, page,
}) {
  ...
  if (search) filter.$text = { $search: search };

  const cursor = db.collection('issues').find(filter)
  ...
}

...

Listing 13-23api/issue.js: Changes to Add a Search Filter

为了测试这些变化,您可以使用操场和我们在 mongo shell 中使用的术语。以下是一个查询示例:

query {
  issueList(search: "click") {
    issues { id title description }
    pages
  }
}

这个查询应该在结果中返回两个文档,与在 mongo shell 中执行时相同。在下一节中,我们将添加 UI 来使用这个 API,并使用搜索栏搜索文档。请注意,尽管 API 允许将其他过滤器值与搜索查询相结合,但 UI 将只使用其中的一个。

搜索栏

让我们使用一个流行的控件,而不是自己实现搜索组件。我选择了 React Select ( https://react-select.com/home ),因为它非常适合这个目的:在用户键入一个单词后,要求异步获取结果并在下拉列表中显示它们,可以选择其中一个。这个组件的Async变体让我们很容易实现这个效果。

让我们首先安装包含组件的包。

$ cd ui
$ npm install react-select@2

让我们在 UI source 目录中创建一个新的组件,它将显示一个 React Select,并使用 List API 中的新搜索过滤器实现获取文档所需的方法。React Select 需要两个回调来显示选项:loadOptionsfilterOptions。第一种是异步方法,需要返回一组选项。每个选项都是一个具有属性labelvalue的对象,label是用户看到的内容,value是唯一的标识符。让我们选择问题 ID 作为value,对于label,让我们将 ID 和问题标题结合起来。

让我们首先实现loadOptions()方法,并使用graphQLFetch()函数获取与搜索词匹配的问题列表。让我们将 API 限制为只对长度超过两个字母的单词触发。

...
  async loadOptions(term) {
    if (term.length < 3) return [];
    const query = `query issueList($search: String) {
      issueList(search: $search) {
        issues {id title}
      }
    }`;

    const data = await graphQLFetch(query, { search: term });
    return data.issueList.issues.map(issue => ({
      label: `#${issue.id}: ${issue.title}`, value: issue.id,
    }));
  }
...

下一个回调函数filterOption将为每个返回的选项调用,以确定是否在下拉列表中显示该选项。这在其他情况下很有用,但是由于使用loadOptions()检索的选项已经是匹配的了,我们可以在回调中返回true。如果我们不提供这个函数,React Select 将应用它自己的匹配逻辑,这不是我们想要的。因此,在render()方法中,我们可以用这两个回调函数返回 React Select,如下所示:

...
import SelectAsync from 'react-select/lib/Async';
...
  render() {
    return (
      <SelectAsync
        loadOptions={this.loadOptions}
        filterOption={() => true}
      />
    );
  }
...

下一步是当用户选择显示的问题之一时采取行动。React Select 提供了一个onChange属性,这是一个当用户选择一个项目时调用的回调函数,所选项目的值作为参数。当发生这种情况时,让我们显示该问题的编辑页面,因为该页面完全显示了一个问题。为了能够做到这一点,我们需要 React 路由的历史可用,所以我们最终需要使用withRouter来导出这个组件。

...
  onChangeSelection({ value }) {
    const { history } = this.props;
    history.push(`/edit/${value}`);
  }
...
  render() {
    return (
      <SelectAsync
        ...
        onChange={this.onChangeSelection}
      />
    );
  }
...

我们将向 React Select 控件添加一些更有用的属性:

  • 如果在同一个页面上使用了多个 React 选择,那么instanceId对于 React Select 来说是很有用的,可以用来识别控件。我们把这个设为search-select。如果没有这个 ID,您会发现 React Select 会自动生成这些 ID,并且会在控制台中显示一个错误,指出服务器呈现的 ID 和客户端呈现的 ID 不匹配。

  • 我们不需要可以用来下拉预加载选项列表的下拉指示器(控件右侧的向下箭头)。因为没有预装选项,所以不需要这样做。对此没有直接的选项,相反,React Select 允许使用components属性定制 React Select 中的每个组件。我们将把DropdownIndicator组件设置为 null,表示不需要显示任何内容。

  • React Select 组件设计用于选择,并在选择后显示选择。我们真的不需要显示选中的项目,所以我们只需将value属性设置为空字符串来实现这一点。

最后,如果在调用 API 时出现错误,最好显示一个错误。所以,让我们用withToast包装组件,并将showError函数提供给 GraphQL fetch函数。通过所有这些改变,新组件的完整代码如清单 13-24 所示。

import React from 'react';
import SelectAsync from 'react-select/lib/Async'; // eslint-disable-line
import { withRouter } from 'react-router-dom';

import graphQLFetch from './graphQLFetch.js';
import withToast from './withToast.jsx';

class Search extends React.Component {
  constructor(props) {
    super(props);

    this.onChangeSelection = this.onChangeSelection.bind(this);
    this.loadOptions = this.loadOptions.bind(this);
  }

  onChangeSelection({ value }) {
    const { history } = this.props;
    history.push('/edit/${value}');
  }

  async loadOptions(term) {
    if (term.length < 3) return [];
    const query = `query issueList($search: String) {
      issueList(search: $search) {
        issues {id title}
      }
    }`;

    const { showError } = this.props;
    const data = await graphQLFetch(query, { search: term }, showError);
    return data.issueList.issues.map(issue => ({
      label: `#${issue.id}: ${issue.title}`, value: issue.id,
    }));
  }

  render() {
    return (
      <SelectAsync
        instanceId="search-select"
        value=""
        loadOptions={this.loadOptions}
        filterOption={() => true}
        onChange={this.onChangeSelection}
        components={{ DropdownIndicator: null }}
      />
    );
  }
}

export default withRouter(withToast(Search));

Listing 13-24ui/src/Search.jsx: New File and Component for the Search Bar

为了将它集成到导航栏中,我们可以在Page.jsx中的两个<Nav>之间添加组件。

...

import Search from './Search.jsx';

...
      <Nav>
        ...
      </Nav>
      <Search />
      <Nav pullRight>
        ...
      </Nav>
...

虽然在功能上这是可行的,但是你会发现标题中搜索栏的对齐方式不正确;搜索控件占据标题的整个宽度,并将右侧Nav推到下一行。为了避免这种情况,我们需要用一个<div>或者限制宽度的东西来包装搜索组件。让我们使用 React-Bootstrap 的Col组件,而不是固定的宽度,它将根据屏幕大小灵活地改变其宽度。此外,正如 React-Bootstrap 的 Navbars 文档中的 https://react-bootstrap.github.io/components/navbar/?no-cache=1#navbars-form 所建议的,为了让控件在Nav中正确对齐,我们需要将它包含在一个Navbar.Form组件中。

清单 13-25 显示了导航栏中的一整套更改。

...
import {
  ...
  Grid, Col,
} from 'react-bootstrap';
...
import IssueAddNavItem from './IssueAddNavItem.jsx';

import Search from './Search.jsx';

...

function NavBar() {
      ...
      <Nav>
        ...
      </Nav>
      <Col sm={5}>
        <Navbar.Form>
          <Search />
        </Navbar.Form>
      </Col>
      <Nav pullRight>
        ...
      </Nav>
}
...

Listing 13-25ui/src/Page.jsx: Changes to Include the Search Control in the Navigation Bar

此时,应用和搜索控件都将工作。你应该能够在条款中输入,并看到一个可以选择的匹配问题下拉列表。例如,如果您键入单词“点击”,两个问题将显示在下拉列表中。选择其中任何一个,页面应该切换到编辑选择的问题。带有下拉菜单的搜索控件截图如图 13-4 所示。

img/426054_2_En_13_Chapter/426054_2_En_13_Fig4_HTML.jpg

图 13-4

问题列表页面中的搜索控件

摘要

在这一章中,我们探讨了各种技术和概念,您可以用它们来实现使应用更加可用的通用特性。

您首先了解了可重用代码的常见 React 模式:高阶组件(hoc)。然后,您看到了如何使用 MongoDB 的聚合框架来汇总甚至扩展从集合中获取的数据。您了解了如何实现通用特性,如何使用第三方组件撤销删除和添加分页,以及如何使用搜索控件在 MongoDB 中基于文本索引查找问题。

在下一章中,我们将讨论如何为问题跟踪器应用实现认证和授权。我们将使用 Google Sign-in 让用户登录到问题跟踪应用。虽然大多数应用将继续对每个人可用,包括那些没有登录的人,但我们将使它只有登录的用户才能对数据进行任何修改。

练习答案

练习:发料数量 API

  1. GraphQL 模式不允许变量模式,也不允许包含未在模式本身中命名的字段的对象。如果我们原样返回对象stats,这将相当于每个对象都有一个未预定义的键(所有者字段的值)。我们无法定义这样一个模式。GraphQL 就是要能够指定哪些键对调用者来说是重要的,而使用一个可变的键将使这变得不可能。

练习:报告页面

  1. 添加行总计是对render()方法的一个小改动。在变量statRows的定义中,你可以在<td> s 的statuses.map生成集之后再加一个<td>,在这个<td>中,你可以这样加那一行的所有计数:

    ...
       <td>{statuses.reduce((total, status) => total + counts[status], 0)}</td>
    ...
    
    
  2. 列总计没有那么简单,您必须减少整个stats数组,并在 reducer 函数中返回一个对象,其中包含每个状态的总计。

练习:分页用户界面

  1. 正如文档所建议的,将计算值存储在状态中并不是一个好主意。取而代之的是,推荐在需要的时候从原始的事实来源计算它们。在计算变得昂贵的情况下,可以使用诸如memorize这样的实用程序来缓存计算出的值。