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

93 阅读19分钟

MERN 技术栈高级教程(六)

原文:Pro MERN Stack

协议:CC BY-NC-SA 4.0

十一、React Bootstrap

像 Bootstrap ( https://getbootstrap.com )和 Foundation ( https://foundation.zurb.com/ )这样的 CSS 框架已经改变了人们构建网站的方式。这些工具使得 web 应用看起来更专业,响应更快(也就是说,让它更好地适应移动屏幕)。当然,缺点是这些现有的框架可能无法为您提供细粒度的可定制性,并且您的应用看起来会像许多其他应用一样。但是,即使您有能力创建自己的端到端定制风格,我也建议您从这些框架开始。这是因为从这些框架使用的模式中可以学到很多东西。

因为我们使用 React 作为 UI 库,所以我们需要选择一些适合 React 并能很好地使用 React 的东西。我评估了 React + Foundation、Material UI 和 React-Bootstrap,因为根据谷歌搜索,它们似乎最受欢迎。

React + Foundation 在功能上似乎与 React-Bootstrap 没有太大区别,但 Bootstrap 本身更受欢迎。Material UI 有一个有趣的 CSS-in-JS 和 inline-style 样式方法,非常适合 React 的哲学,即将组件所需的一切与组件本身隔离开来。但是这个框架不太受欢迎,似乎是一项正在进行的工作。而且,也许内联式方法过于偏离常规。

React-Bootstrap 是一个安全的选择,它建立在非常流行的 Bootstrap 之上,符合我们的需要(除了缺少日期选择器)。因此,我为这本书选择了 React-Bootstrap。在这一章中,我们将看看如何使用 React-Bootstrap 让应用看起来更专业。我不会讨论如何定制主题和其他高级主题,但是你将学到足够的东西来理解 React-Bootstrap 是怎么回事,所以如果需要的话,你可以很容易地进一步学习。

Bootstrap 安装

在这一节中,我们将安装 React-Bootstrap,并通过在 UI 中做一个可见的小更改来确认它是否工作。让我们首先安装 React-Bootstrap:

$ cd ui
$ npm install react-bootstrap@0

React-Bootstrap 包含一个 React 组件库,本身没有 CSS 样式或主题。要使用这些组件,需要在应用中包含 Bootstrap 样式表。包含样式表的版本或机制由我们决定,但是我们需要使用的版本是版本 3。React-Bootstrap 尚不支持最新版本的 Bootstrap 程序(版本 4)。因此,让我们包括 bootstrap 样式表的版本 3。最简单的方法是从 CDN 直接将其包含在index.html中,这是 React-Bootstrap“入门”页面( https://react-bootstrap.github.io )所推荐的。

但是因为我们在本地安装了其他第三方 JavaScript 依赖项,所以让我们对 Bootstrap 也做同样的事情。让我们使用 npm 安装 bootstrap,这样它的发行版文件就可以使用,并且可以直接从服务器提供服务,就像index.html和其他静态文件一样。

$ npm install bootstrap@3

下一步是在应用中包含 Bootstrap 样式表。一种方法是使用 Webpack 的样式和 CSS 加载器。这可以通过使用一个import(或require)语句来包含 CSS 文件来实现,就像其他 React 或 JavaScript 模块一样。然后,Webpack 将构建依赖关系树,并在创建的包中包含所有已经导入的样式。这是通过在包含所有样式的 JavaScript 包中创建一个字符串来实现的。当应用被加载时,字符串作为一个<style>Node 被放入 DOM。

为了让它工作,我们需要为 Webpack 安装 CSS 和样式加载器。然后,我们需要在 Webpack 配置中添加模式匹配,根据文件扩展名触发这些加载器。我们还需要样式表可能包含的图标和字体的加载器。最后,我们需要一个单独的import语句来导入bootstrap.css,可能只是在App.jsx中。

我发现对于我们的需求来说,所有这些都是多余的。Webpack 的 CSS 和样式加载器的目的是能够模块化样式表,就像我们模块化 React 代码一样。如果每个组件都有自己的一组样式,并被分离到它们自己的 CSS 文件中,这种方法会非常有效。但事实是 Bootstrap 是作为一个整体样式表提供的。即使只使用了一个组件,也必须包含整个 CSS。那么为什么不直接包含整个样式表呢?这就是我们要做的。

我们将在public目录下保留一个到 Bootstrap 程序发行版的符号链接,并像其他静态文件(如index.html)一样包含 CSS。在 Mac 或基于 Linux 的计算机上实现这一点的命令是:

$ ln -s ../node_modules/bootstrap/dist public/bootstrap

在 Windows PC 上,命令行工具mklink可以用来做同样的事情,用/J选项为目录创建一个连接

> mklink /J public\bootstrap node_modules\bootstrap\dist

或者,您可以将 Bootstrap 库下的整个dist目录复制到公共目录中。

如果您现在浏览这个新目录,您会发现三个子目录:cssfontsjs。不会使用js目录,因为这是 React-Bootstrap 所替换的。在index.html中,我们将添加一个到主 Bootstrap 样式表的链接,它位于我们链接或复制的目录的css子目录下。我们将不包括可选的主题文件,只包括主要的简化 Bootstrap 样式,如下所示:

...
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
...

此时,如果您测试应用,您应该会看到一个不同外观的应用,因为有了新的 Bootstrap 样式表。例如,默认字体应该是无衬线字体(Helvetica 或 Arial ),而不是衬线字体(Times New Roman)。

现在让我们在移动设备上测试一下,看看自举响应设计的效果。做到这一点的一个方法是实际使用移动电话并连接到运行在你的桌面上的服务器。但是这不会起作用,除非你把环境变量UI_API_ENDPOINT改成你的计算机的 IP 地址。或者,您必须使用代理配置,以便所有请求都通过 UI 服务器路由。

更简单的方法是使用 web 浏览器的移动仿真模式来查看它的外观。我发现只有 Chrome 准确地反映了真实移动设备中发生的事情。Safari(使用开发菜单下的响应模式)和 Firefox(在开发工具中)只模拟屏幕大小的变化。图 11-1 显示了你在真实移动设备或 Chrome 移动模拟器上看到的内容。请注意,某些设备可能看不到轮廓或设备框架。此外,需要在开发人员工具设置中打开显示设备框架选项才能看到设备框架。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig1_HTML.jpg

图 11-1

移动模拟器中的应用

正如你所见,屏幕看起来真的压扁或缩小。你可以通过挤压(在移动模拟器中使用 Shift-drag)来改变扩容比例,但是我们真的希望它默认使用较小的屏幕宽度。移动浏览器不采用设备宽度的原因大致是这样的:它假设页面不是为移动屏幕设计的,所以它选择一个适合桌面的任意宽度,使用它,然后缩小页面以适应屏幕。

我们需要让应用知道如何处理小屏幕的移动浏览器,这样它就不会做额外的工作来尝试将桌面网站放入移动屏幕。方法是在主页面中添加一个名为viewport的 meta 标签,其内容指定初始宽度等于设备的宽度,初始扩容为 100%。清单 11-1 显示了这一变化以及index.html中包含的样式表。

...
<head>
  ...
  <title>Pro MERN Stack</title>
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css" >
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
...

Listing 11-1ui/public/index.html: Changes for Bootstrap and Mobile Device Compatibility

通过这一改变,屏幕应该看起来更好,屏幕填充整个设备宽度,如图 11-2 所示。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig2_HTML.jpg

图 11-2

视口设置后的移动模拟器

我们测试了 Bootstrap 样式表的生效,并在移动浏览器中进行了检查。现在让我们测试一下 React-Bootstrap 的组件是否可用。为此,我们使用一个简单的 React-Bootstrap 组件。可用组件列表可在 https://react-bootstrap.github.io/components/alerts/ 的 React-Bootstrap 文档中找到。让我们选择<Label>组件,这是一个简单的组件,以突出显示的方式显示文本。让我们在问题列表的标题中使用它,并确保它按预期呈现。对此的更改在IssueList组件中进行,如清单 11-2 所示。

...
import { Route } from 'react-router-dom';

import { Label } from 'react-bootstrap';

...

  render() {
    ...
    return (
      <React.Fragment>
        <h1><Label>Issue Tracker</Label></h1>
    ...
  }
...

Listing 11-2ui/src/IssueList.jsx: Change the App Title to Use React-Bootstrap’s Label Component

现在,如果您测试应用,您会发现问题列表页面中的标题显示为深色背景和白色前景。其截图如图 11-3 所示。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig3_HTML.jpg

图 11-3

带有 Bootstrap 标签的问题列表页面

小跟班

为了熟悉 React-Bootstrap 使用组件的方式,让我们从一个简单的组件开始:Button。让我们用 Bootstrap 按钮替换问题过滤器中的应用和重置按钮。

可以使用<Button>组件创建一个简单的基于文本的按钮。除了常规的<button>组件支持的所有属性之外,<Button>组件使用bsStyle属性来使按钮看起来与众不同。除了默认显示白色背景的按钮,允许的样式有primarysuccessinfowarningdangerlink。让我们使用primary样式的应用按钮和默认的重置按钮。因此,对于复位按钮,唯一的变化是标签名称从button变为Button。应用按钮的变化是标签名称和添加了bsStyle="primary"

这些变化如清单 11-3 所示。

...
import { withRouter } from 'react-router-dom';

import { Button } from 'react-bootstrap';

...

        <button type="button" onClick={this.applyFilter}>Apply</button>
        <Button bsStyle="primary" type="button" onClick={this.applyFilter}>
          Apply
        </Button>
        {' '}
        <button
        <Button
          ...
        >
          Reset
        </button>
        </Button>
...

Listing 11-3ui/src/IssueFilter.jsx: Replace Buttons with Bootstrap Buttons

下一步,让我们在问题表中使用图标而不是文本来关闭和删除问题。我们将需要使用 React-Bootstrap 的Glyphicon组件,而不是<Button>元素中的文本。该组件可识别的图标列表可从位于 https://getbootstrap.com/docs/3.3/components/ 的 Bootstrap 网站获得。让我们在IssueRow组件中用remove图标表示关闭动作,用trash图标表示删除动作,来代替常规按钮。让我们使用一个更小的按钮,通过使用bsSize属性使表格看起来更紧凑。关闭动作的代码如下,它保留了最初的onClick()事件。

...
        <Button bsSize="xsmall" onClick={() => { closeIssue(index); }}>
          <Glyphicon glyph="remove" />
        </Button>
...

可以对删除操作的按钮进行类似的更改。但是由于图标的预期动作并不太明显,所以最好在鼠标悬停在按钮上时显示一个工具提示。HTML 属性title可以用于这个目的,但是让我们使用 Bootstrap 的风格化的Tooltip组件。但是使用它并不像设置一个title属性那么简单。Tooltip组件必须在鼠标经过和鼠标离开时显示或隐藏。在常规的 Bootstrap 中(也就是没有 React),jQuery 将用于注入这些处理程序,但是在 React 中,我们需要一个适合组件层次结构的更干净的机制。

方法是使用包装按钮的OverlayTrigger组件,并将Tooltip组件作为属性。Tooltip本身很简单:元素的子元素是要显示的内容。此外,由于工具提示的默认位置是在按钮的右边,如果按钮靠近屏幕边缘,这可能会变得模糊,让我们将位置更改为按钮上方。为此,我们可以将placement属性指定为top

...
  const closeTooltip = (
    <Tooltip id="close-tooltip" placement="top">Close Issue</Tooltip>
  );
...

需要使用id属性来使组件可访问。现在,这个变量可以用作OverlayTrigger组件中的一个属性,我们将为工具提示效果包装这个按钮。此外,让我们自定义工具提示显示的延迟时间,因为默认的延迟时间非常短且具有干扰性。这可以通过使用OverlayTriggerdelayShow属性来完成。

...
        <OverlayTrigger delayShow={1000} overlay={closeTooltip}>
          <Button bsSize="xsmall" onClick={() => { closeIssue(index); }}>
            <Glyphicon glyph="remove" />
          </Button>
        </OverlayTrigger>
...

用于删除动作的类似组件集,以及import语句等。完成对IssueTable.jsx文件的修改,如清单 11-4 所示。

...
import { Link, NavLink, withRouter } from 'react-router-dom';

import {

  Button, Glyphicon, Tooltip, OverlayTrigger,

} from 'react-bootstrap';

const IssueRow = withRouter(({
  ...
  const selectLocation = { pathname: `/issues/${issue.id}`, search };
  const closeTooltip = (
    <Tooltip id="close-tooltip" placement="top">Close Issue</Tooltip>
  );
  const deleteTooltip = (
    <Tooltip id="delete-tooltip" placement="top">Delete Issue</Tooltip>
  );
  return (
    ...
        <button type="button" onClick={() => { closeIssue(index); }}>
          Close
        </button>
        <OverlayTrigger delayShow={1000} overlay={closeTooltip}>
          <Button bsSize="xsmall" onClick={() => { closeIssue(index); }}>
            <Glyphicon glyph="remove" />
          </Button>
        </OverlayTrigger>
        {' | '}
        {' '}
        <OverlayTrigger delayShow={1000} overlay={deleteTooltip}>
          <Button bsSize="xsmall" onClick={() => { deleteIssue(index); }}>
            <Glyphicon glyph="trash" />
          </Button>
        </OverlayTrigger>
      </td>
   ...
});
...

Listing 11-4ui/src/IssueTable.jsx: Changes for Buttons with Icons and Tooltip

注意

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

我们将把编辑和选择链接转换成图标,以便以后使用。这是因为,作为链接,它们需要很好地与 React Router 一起工作,并无缝地处理<Link><NavLink>元素。如果您使用当前的更改测试应用,您应该会发现问题列表页面看起来类似于图 11-4 中的屏幕截图。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig4_HTML.jpg

图 11-4

带有 Bootstrap 按钮和关闭按钮上的悬停工具提示的问题列表

导航栏

在这一节中,我们将设计页眉中的导航链接,并添加一个在所有页面上都可见的页脚。

对于导航栏,我们从应用标题开始。我们将把它从问题列表页面移到它应该在的导航栏。主页、问题列表和报告链接可以显示为标题右侧的样式化导航链接。在右侧,我们还有一个用于创建新问题的操作项(在后面的部分中,我们将把页面内添加表单移动到一个模式中)和一个用于将来可能添加的任何其他操作的扩展下拉菜单。目前,扩展菜单只有一个动作,叫做 About(暂时不做任何事情)。

创建导航栏的起始组件是Navbar。让我们首先解决导航栏的布局和实现它所需的组件。每一项都是一个NavItem。这些项目可以在一个Nav中组合在一起。因此,我们需要两个Nav元素,一个用于导航栏的左侧,另一个用于右侧。使用pullRight属性可以将右侧Nav向右对齐。

...
  <Navbar>
    <Nav>
      <NavItem>Home</NavItem>
      <NavItem>Issue List</NavItem>
      <NavItem>Report</NavItem>
    </Nav>
    <Nav pullRight>
      ...
    </Nav>
  </Navbar>
...

至于应用标题,让我们按照文档的建议使用Navbar.HeaderNavbar.Brand。这应该出现在所有的Nav元素之前。

...
  <Navbar>
    <Navbar.Header>
      <Navbar.Brand>Issue Tracker</Navbar.Brand>
    </Navbar.Header>
...

在右侧,我们首先需要一个用于创建问题操作的基于图标的项目。我们将使用NavItem中的Glyphicon组件来实现这一点。此外,为了给图标添加一个工具提示,让我们像在问题列表页面中使用动作按钮一样使用OverlayTrigger

...
      <Nav pullRight>
        <NavItem>
          <OverlayTrigger ...
            <Glyphicon glyph="plus" />
          </OverlayTrigger
        </NavItem>
...

至于扩展菜单,我们需要一个下拉菜单。React-Bootstrap 组件NavDropdown可用于创建下拉菜单,每个菜单项都是一个MenuItem组件。下拉列表需要一个标题,通常是文本。但是由于菜单是通用的,我们将只使用一组垂直方向的三个点来表示扩展菜单是可用的。通常,当使用文本时,插入符号将指示下拉列表。但是因为我们使用了一个已经表示下拉列表的图标,我们将通过指定noCaret作为属性来移除插入符号。

...
        <NavDropdown
          id="user-dropdown"
          title={<Glyphicon glyph="option-vertical" />}
          noCaret
        >
          <MenuItem>About</MenuItem>
        </NavDropdown>
...

现在,让我们添加导航项应该执行的操作。NavItem组件可以接受一个href作为属性,或者一个onClick事件处理程序。现在,我们有以下选择:

  • NavItem上使用一个href属性:这有一个问题,React 路由的<Link>将不被使用,点击href会导致浏览器完全刷新。

  • 使用 React Router 的<Link>而不是NavItem:这将搞乱样式,不能正确对齐,因为<Link>使用锚标签(<a>),并且没有办法改变组件类。

这两种选择都会带来问题。打破这个僵局的推荐方法是使用react-router-bootstrap包,它提供了一个名为LinkContainer的包装器,充当 React 路由的NavLink,同时让它的孩子拥有自己的渲染。我们可以将一个没有hrefNavItem作为子 Node 放在LinkContainer上,让父 NodeLinkContainer处理到路由的路径。

让我们安装软件包来使用LinkContainer:

$ cd ui
$ npm install react-router-bootstrap@0

现在,我们可以用一个LinkContainer(在导入包之后)将所有左侧的导航项与它需要指向的路线的路径包装在一起。组件LinkContainer支持 React 路由组件NavLink的所有属性。

...
        <LinkContainer to="/issues">
          <NavItem>Issue List</NavItem>
        </LinkContainer>
...

让我们添加一个简单的页脚,显示这本书的 GitHub 资源库的链接。包括这一变化,Page.jsx文件的全部内容如清单 11-5 所示。为了简洁起见,我排除了被删除的代码行,这些被删除的代码行只是原始的NavLink和相应的import语句。

import React from 'react';
import {
  Navbar, Nav, NavItem, NavDropdown,
  MenuItem, Glyphicon, Tooltip, OverlayTrigger,
} from 'react-bootstrap';
import { LinkContainer } from 'react-router-bootstrap';

import Contents from './Contents.jsx';

function NavBar() {
  return (
    <Navbar>
      <Navbar.Header>
        <Navbar.Brand>Issue Tracker</Navbar.Brand>
      </Navbar.Header>
      <Nav>
        <LinkContainer exact to="/">
          <NavItem>Home</NavItem>
        </LinkContainer>
        <LinkContainer to="/issues">
          <NavItem>Issue List</NavItem>
        </LinkContainer>
        <LinkContainer to="/report">
          <NavItem>Report</NavItem>
        </LinkContainer>
      </Nav>
      <Nav pullRight>
        <NavItem>
          <OverlayTrigger
            placement="left"
            delayShow={1000}
            overlay={<Tooltip id="create-issue">Create Issue</Tooltip>}
          >
            <Glyphicon glyph="plus" />
          </OverlayTrigger>
        </NavItem>
        <NavDropdown
          id="user-dropdown"
          title={<Glyphicon glyph="option-vertical" />}
          noCaret
        >
          <MenuItem>About</MenuItem>
        </NavDropdown>
      </Nav>
    </Navbar>
  );
}

function Footer() {
  return (
    <small>
      <p className="text-center">
        Full source code available at this
        {' '}
        <a href="https://github.com/vasansr/pro-mern-stack-2">
          GitHub repository
        </a>
      </p>
    </small>
  );
}

export default function Page() {
  return (
    <div>
      <NavBar />
      <Contents />
      <Footer />
    </div>
  );
}

Listing 11-5ui/src/Page.jsx: New NavBar-Based Header and Trivial Footer

既然应用标题是导航栏的一部分,让我们将它从问题列表页面中删除。对此的更改如清单 11-6 所示。

...
import { Route } from 'react-router-dom';

import { Label } from 'react-bootstrap';

...

      <React.Fragment>
        <h1><Label>Issue Tracker</Label></h1>
...

Listing 11-6ui/src/IssueList.jsx: Removal of Application Title

现在,如果您测试应用,您应该会看到如图 11-5 中截图所示的问题列表页面。您还应该能够使用导航栏在问题列表页面和报告页面之间切换,并且导航栏也应该在编辑屏幕中可见。导航栏右侧的项目仍然是没有效果的虚拟项目。我们将在后面的章节中实现这些。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig5_HTML.jpg

图 11-5

带有导航栏的问题列表页面

练习:导航栏

  1. React-Bootstrap 的Navbar at https://react-bootstrap.github.io/components/navbar/ 文档列出了对一个名为fixedTop的属性的支持。这是为了在垂直滚动时保持NavBar在顶部。试试这个。您可能需要转到编辑页面和/或降低屏幕高度,以显示垂直滚动条。你发现问题了吗?你会怎么解决?提示:在 https://getbootstrap.com/docs/3.3/components/#navbar-fixed-top 参考 Bootstrap 的 Navbar 文档。(请记住在练习后恢复这些实验变化。)

本章末尾有答案。

嵌板

到目前为止,我们一直使用水平标尺(<hr>元素)来划分页面中的部分。例如,在问题列表页面中,我们使用它来将过滤器从问题表中分离出来。此外,在包含 Bootstrap 之后,您会注意到左边空白消失了,导致问题列表页面的所有内容出现在左侧窗口边缘的右侧。

Bootstrap 的Panel组件是使用边框和可选标题分别显示部分的好方法。让我们用它来装饰问题列表页面中的过滤器部分。我们将通过在render()方法中的IssueFilter实例周围添加一个面板来对IssueList组件进行更改。

Panel组件由可选标题(Panel.Heading)和面板主体(Panel.Body)组成。面板主体是我们放置<IssueFilter />实例的地方。

...
        <Panel>
          ...
          <Panel.Body>
            <IssueFilter />
          </Panel.Body>
        </Panel>
...

至于标题,让我们为它添加一个带有文本Filter的标题。不使用纯文本,而是将它包装在一个用于面板标题的Panel.Title组件中,使其突出。

...
          <Panel.Heading>
            <Panel.Title>Filter</Panel.Title>
          </Panel.Heading>
...

现在,为了节省空间,让我们折叠面板。当用户想要应用过滤器时,他们可以点击标题并显示和操作过滤器。方法是将collapsible属性添加到Panel.Body中,并通过设置面板标题的toggle属性来控制折叠行为。

...
          <Panel.Heading>
            <Panel.Title toggle>Filter</Panel.Title>
          </Panel.Heading>
          <Panel.Body collapsible>
...

清单 11-7 显示了围绕问题过滤器实现可折叠面板的完整更改。点击标题Filter,可折叠/展开面板。

...
import { Route } from 'react-router-dom';

import { Panel } from 'react-bootstrap';

...

  render() {
    ...
    return (
      <React.Fragment>
        <Panel>
          <Panel.Heading>
            <Panel.Title toggle>Filter</Panel.Title>
          </Panel.Heading>
          <Panel.Body collapsible>
            <IssueFilter />
          </Panel.Body>
        </Panel>
        <IssueTable
        ...
      </React.Fragment>
    );
  }
...

Listing 11-7ui/src/IssueList.jsx: Changes for Adding a Panel Around the Issue Filter

即使问题过滤器周围有一个面板,在测试应用时,你会发现没有左边距。Bootstrap 的网格系统是增加余量的系统。虽然我们还不会使用网格,但是我们需要用一个<Grid>组件来包装页面主体,以增加边距。让我们在Page.jsx中的Contents组件实例周围添加网格组件,而不是对每个页面都这样做。

Bootstrap 中有两种网格容器:一种是填充整个页面的流动容器,另一种是固定容器(默认),其大小是固定的,但可以适应屏幕大小。让我们使用一个使用fluid属性的流体网格来匹配导航条,因为让问题列表充满屏幕以获得更好的可读性会很好。

这一变化如清单 11-8 所示。

...
import {
  ...
  Grid,
} from 'react-bootstrap';
...

export default function Page() {
      <Grid fluid>
        <Contents />
      </Grid>
  );
}
...

Listing 11-8ui/src/Page.jsx: Wrapping Page Contents with a Grid to Add Margins

当您尝试这一组更改时,您会发现面板标题不太容易点击。首先,光标不会变成可以点击的东西。另外,你唯一能点击的地方是在文本上。出于可用性的考虑,我们真正想要的是光标指示它是可点击的,并且让整个标题区域都是可点击的。

如果您使用 Safari 或 Chrome 的检查器检查 DOM,您可以看到有一个由 React-Bootstrap 在标题可折叠时添加的<a>元素。不幸的是,我们没有办法配置面板,要么不添加<a>(让您自己为标题指定一个可点击的 Node),要么告诉它填充水平空间。我们可以做到这一点的唯一方法是使用一种样式,使<a>成为填充空间和设置光标的块元素。

让我们在index.html中加入这种风格。清单 11-9 显示了这一变化。

...
  <style>
    ...
    .panel-title a {display: block; width: 100%; cursor: pointer;}
  </style>
...

Listing 11-9public/index.html: Style for Making Entire Panel Heading Clickable

现在,在测试中,你会发现页面的内容和窗口的边缘之间有边距。您必须刷新浏览器,因为样式已更新的index.html不会被 HMR 自动更新。您还会发现有一个可点击的面板标题,点击它可以打开过滤器表单。其截图如图 11-6 所示。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig6_HTML.jpg

图 11-6

可折叠面板中的问题过滤器

练习:面板

  1. 假设我们希望面板在浏览器刷新时以展开状态显示。你将如何实现这一目标?更有趣的是,如果除了默认的“所有状态”和“所有工作”之外的任何过滤器有效,我们希望面板被展开。你将如何实现这一目标?提示:在 https://react-bootstrap.github.io/components/panel/#panels-props-accordion 的 React-Bootstrap 文档中查找Panel组件的属性。

本章末尾有答案。

桌子

除了样式化之外,Bootstrap 还可以为表格添加一些细节。在这一节中,我们将把普通表转换成 Bootstrap 表,Bootstrap 表看起来更好,扩展到适合屏幕,并在悬停时突出显示一行。此外,我们将使整行可点击以选择问题,以便其描述显示在详细信息部分。这将替换Action列中的Select链接。我们将使用react-router-bootstrap中的LinkContainer组件来实现这一点。我们还将把编辑链接转换成一个按钮,我们不能和其他两个动作按钮一起做,因为那时我们还没有发现LinkContainer组件。

在本节中,我们将使用IssueTable.jsx文件中的新组件LinkContainerTable,所以让我们先导入它们:

...

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

import {
  Button, Glyphicon, Tooltip, OverlayTrigger, Table,
} from 'react-bootstrap';
...

将表转换为 Bootstrap 表非常简单,只需用 React-Bootstrap 的<Table>组件标签替换<table>标签。在我们这样做的同时,让我们也添加一些属性,这些属性记录在 React-Bootstrap 的 https://react-bootstrap.github.io/components/table/ 的表格页面中。

  • striped:高亮显示具有不同背景的交替行。这可能会干扰所选行的显示,所以我们不要使用它。

  • bordered:在行和单元格周围添加边框。让我们使用这个。

  • condensed:默认大小文字周围空白太多,我们用精简模式吧。

  • hover:高亮光标下的行。让我们使用这个。

  • responsive:在较小的屏幕上,使表格水平滚动,而不是减少列的宽度。我们也用这个吧。

IssueTable组件的render()函数中的新表格组件现在看起来像这样:

...
    <table className="bordered-table">
    <Table bordered condensed hover responsive>
      ...
    </table>
    </Table>
...

接下来,要用整行替换Select链接,让我们用一个LinkContainer包装整行,并让它导航到与使用to属性的Select链接相同的位置。注意,如果路由匹配链接路径,LinkContainer还会自动将active类添加到类似NavLink的包装元素中。Bootstrap 自动用灰色背景突出显示这些行。因此,我们不返回IssueRow组件的render()方法中的<tr>元素,而是将它赋给一个常量。然后,让我们返回包装在一个LinkContainer周围的相同元素。

...
  return (
  const tableRow = (
    <tr>
      ...
        <NavLink to={selectLocation}>Select</NavLink>
        {' | '}
      ...
    </tr>
  );

  return (
    <LinkContainer to={selectLocation}>
      {tableRow}
    </LinkContainer>
  );
...

现在,让我们将编辑链接转换成一个带有图标的按钮。让我们使用ButtonGlyphicon组件,并像关闭和删除按钮一样使用TooltipOverlayTrigger,除了onClick()事件处理程序:

...
  const selectLocation = { pathname: `/issues/${issue.id}`, search };
  const editTooltip = (
    <Tooltip id="close-tooltip" placement="top">Edit Issue</Tooltip>
  );
...
        <Link to={`/edit/${issue.id}`}>Edit</Link>
        {' | '}
        <OverlayTrigger delayShow={1000} overlay={editTooltip}>
          <Button bsSize="xsmall">
            <Glyphicon glyph="edit" />
          </Button>
        </OverlayTrigger>
...

现在,让我们用一个LinkContainer来包装按钮,而不是onClick()事件处理程序;to属性是从原Link组件的to属性复制而来的:

...
        <LinkContainer to={`/edit/${issue.id}`}>
          <OverlayTrigger delayShow={1000} overlay={editTooltip}>
            ...
          </OverlayTrigger>
        </LinkContainer>
...

在这个时间点上,更改似乎会起作用,但是单击关闭或删除按钮也会产生选择行的副作用。这是因为我们现在在行上有了一个onClick()处理程序(由LinkContainer组件安装),当单击这些按钮时就会调用它。为了防止事件从按钮传播到包含的行,我们需要在处理程序中调用e.preventDefault()。让我们将事件处理程序分成显式函数(相对于onClick属性中的匿名函数)并使用函数名。

对于“删除”按钮,更改如下:

...
  function onDelete(e) {
    e.preventDefault();
    deleteIssue(index);
  }

  const tableRow = (
  ...
          <Button bsSize="xsmall" onClick={() => { deleteIssue(index); }}>
          <Button bsSize="xsmall" onClick={onDelete}>
  );
...

我们需要对 Close 按钮进行类似的更改(为了简洁起见,没有显示)。清单 11-10 显示了对IssueTable.jsx文件的完整更改。

...
import { Link, NavLink, withRouter } from 'react-router-dom';

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

import {
  Button, Glyphicon, Tooltip, OverlayTrigger, Table,
} from 'react-bootstrap';

const IssueRow = withRouter(({
  ...
  const selectLocation = { pathname: `/issues/${issue.id}`, search };
  const editTooltip = (
    <Tooltip id="close-tooltip" placement="top">Edit Issue</Tooltip>
  );
  ...
  const deleteTooltip = (
    ...
  );

  function onClose(e) {
    e.preventDefault();
    closeIssue(index);
  }

  function onDelete(e) {
    e.preventDefault();
    deleteIssue(index);
  }

  return (
  const tableRow = (
    ...
        <Link to={`/edit/${issue.id}`}>Edit</Link>

        {' | '}
        <LinkContainer to={`/edit/${issue.id}`}>
          <OverlayTrigger delayShow={1000} overlay={editTooltip}>
            <Button bsSize="xsmall">
              <Glyphicon glyph="edit" />
            </Button>
          </OverlayTrigger>
        </LinkContainer>
        {' '}
        <NavLink to={selectLocation}>Select</NavLink>
        {' | '}
    ...
          <Button bsSize="xsmall" onClick={() => { closeIssue(index); }}>
          <Button bsSize="xsmall" onClick={onClose}>
    ...
          <Button bsSize="xsmall" onClick={() => { deleteIssue(index); }}>
          <Button bsSize="xsmall" onClick={onDelete}>
    ...
  );

  return (
    <LinkContainer to={selectLocation}>
      {tableRow}
    </LinkContainer>
  );
});
...

export default function IssueTable({ issues, closeIssue, deleteIssue }) {
  ...
  return (
    <table>
    <Table bordered condensed hover responsive>

      ...
    </table>
    </Table>
    ...
  );
}
...

Listing 11-10ui/src/IssueTable.jsx: Changes for Using Bootstrap Table, Clickable Rows, and Edit Button

我们还可以通过将光标变为指针来表明表格行是可点击的。此外,我们现在可以删除用于为原始表格设置边框的样式,以及显示活动时高亮显示的NavLink。清单 11-11 显示了样式的这些变化。

...
  <style>
    table.bordered-table th, td {border: 1px solid silver; padding: 4px;}
    table.bordered-table {border-collapse: collapse;}
    a.active {background-color: #D8D8F5;}
    table.table-hover tr {cursor: pointer;}
...

Listing 11-11ui/public/index.html: Removal of Old Styles and New Style for Table Rows

如果您现在测试应用,您会发现表格看起来有所不同。它现在水平填充屏幕,光标下的行高亮显示。您可以单击该行进行选择,而不是使用旧的选择链接。点击关闭或删除按钮应该而不是选择问题。使用移动仿真器的快速测试也应该确认该表是可水平滚动的。新问题列表页面截图如图 11-7 所示。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig7_HTML.jpg

图 11-7

带有 Bootstrap 表的问题列表页面

形式

可以使用 Bootstrap 以多种方式设计表单的样式和布局。但是在开始布局表单之前,让我们首先使用库提供的基本组件,用 React-Bootstrap 的等价物和标签来替换简单的<input><select>选项。让我们在问题过滤器表单中完成此操作。

在本节中,我们将使用许多新的 React-Bootstrap 组件。我们先在IssueFilter.jsx中导入。

...
import {
  ButtonToolbar, Button, FormGroup, FormControl, ControlLabel, InputGroup,
} from 'react-bootstrap';
...

使用 React-Bootstrap,使用一个FormControl实例化公共输入类型。默认情况下,它使用常规的<input>类型来呈现实际的元素。componentClass属性可以用来将这个默认值更改为任何其他元素类型,例如select。表单控件的其余属性,如valueonChange,与常规的<input><select>元素相同。

可以使用ControlLabel组件将标签与表单控件相关联。该组件的唯一子组件是标签文本。为了将标签和控件放在一起,它们需要放在一个FormGroup下。例如,状态筛选器的下拉列表及其标签可以改写如下:

...
        <FormGroup>
          <ControlLabel>Status:</ControlLabel>
          <FormControl
            componentClass="select"
            value={status}
            onChange={this.onChangeStatus}
          >
            <option value="">(All)</option>
            ...
          </FormControl>
        </FormGroup>
...

Effort输入不是那么简单,因为它们由两个输入组成。我们可以用一个InputGroup来包围两个FormControls,但是它本身会导致两个输入一个在另一个下面。InputGroup.Addon组件可用于显示相邻的输入,以及显示两个输入之间的破折号。

...
        <FormGroup>
          <ControlLabel>Effort between:</ControlLabel>
          <InputGroup>
            <FormControl value={effortMin} onChange={this.onChangeEffortMin} />
            <InputGroup.Addon>-</InputGroup.Addon>
            <FormControl value={effortMax} onChange={this.onChangeEffortMax} />
          </InputGroup>
        </FormGroup>
...

我们在两个按钮之间使用了空格字符。更好的方法是使用ButtonToolbar组件。

...
        <ButtonToolbar>
          <Button ...>
            Apply
          </Button>
          {' '}
          <Button
            ...
          >
            Reset
          </Button>
        </ButtonToolbar>
...

清单 11-12 中显示了对IssueFilter组件的完整更改。

...

import { Button } from 'react-bootstrap';

import {

  ButtonToolbar, Button, FormGroup, FormControl, ControlLabel, InputGroup,

} from 'react-bootstrap';

...

  render() {
    ...
    return (
      <div>
        Status:
        {' '}
        <select value={status} onChange={this.onChangeStatus}>
          <option value="">(All)</option>
          ...
        </select>
        <FormGroup>
          <ControlLabel>Status:</ControlLabel>
          <FormControl
            componentClass="select"
            value={status}
            onChange={this.onChangeStatus}
          >
            <option value="">(All)</option>

            ...
          </FormControl>
        </FormGroup>
        {' '}
        Effort between:
        {' '}
        <input
          size={5}
          value={effortMin}
          onChange={this.onChangeEffortMin}
        />
        {' - '}
        <input
          size={5}
          value={effortMax}
          onChange={this.onChangeEffortMax}
        />
        <FormGroup>
          <ControlLabel>Effort between:</ControlLabel>
          <InputGroup>
            <FormControl value={effortMin} onChange={this.onChangeEffortMin} />
            <InputGroup.Addon>-</InputGroup.Addon>
            <FormControl value={effortMax} onChange={this.onChangeEffortMax} />
          </InputGroup>
        </FormGroup>
        {' '}
        <ButtonToolbar>
          <Button ...>
            Apply
          </Button>
          {' '}
          <Button ...>
            Reset
          </Button>
        </ButtonToolbar>

      </div>
    );
  }
...

Listing 11-12ui/src/IssueFilter.jsx: Replace Inputs with Bootstrap Form Controls

在使用 Bootstrap 表单控件的这些更改之后,应用的功能应该没有变化。过滤器现在看起来不同了,如图 11-8 中的截图所示。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig8_HTML.jpg

图 11-8

使用 Bootstrap 表单控件的问题过滤器

网格系统

表单在问题过滤器中的默认布局不是很好,因为它在垂直方向上占用了很多空间,并且输入控件不必要的宽。但是这对于窄屏幕或页面中的窄部分可能很有效。

处理这个问题的一个更好的方法是使用 Bootstrap 的网格系统,让每个字段(包括标签)浮动,也就是说,占据其先例旁边的空间,或者如果屏幕宽度不允许的话,占据其先例下面的空间。问题过滤器是这种行为的一个很好的用例,因为我们希望看到它水平放置,但是在较小的屏幕上,一个在另一个下面。

网格系统是这样工作的:水平空间被分成最多 12 列。一个单元格(使用组件Col)可以在不同的屏幕宽度上占据一列或多列以及不同数量的列。如果一行中的列间距超过 12 个单元格,则单元格会换行(Row组件)。如果需要强制单元格流动中断,则需要新的一行。对于表单来说,使用网格系统的最好方式是有一个单独的行,并指定每个表单控件(一个单元格)在不同的屏幕宽度上占据多少列。例如:

...
<Grid fluid>
  <Row>
    <Col xs={4}>...</Col>
    <Col xs={6}>...</Col>
    <Col xs={3}>...</Col>
  </Row>
</Grid>
...

这个网格有一行三个单元格,分别占据四、六、三列。xs属性表示“极小”的屏幕宽度,因此,这些单元格宽度仅适用于移动设备。其他屏幕尺寸的宽度分配可以使用smmdlg来指定,它们分别代表小、中和大屏幕。如果未指定,将使用小于此尺寸的适用于屏幕尺寸的值。因此,仅使用xs将意味着所有屏幕尺寸使用相同的单元格宽度。

然后,Bootstrap 负责将它们进行布局,并决定如何根据不同的屏幕宽度来调整单元格。在过滤器中,我们有三个宽度大致相等的单元格:状态输入(带标签)、工作输入(带标签)和按钮(在一起)。即使在非常小的屏幕上,我们也不想将输入或按钮分成多行,所以我们将把它们分别视为一个单元格。

让我们从最小的屏幕尺寸开始:移动设备。让我们使用每个单元格一半的屏幕宽度。这意味着我们将状态和努力放在一行,按钮放在下一行。这可以通过指定xs={6}来实现,即总共可用的 12 列的一半。您可能想知道为什么三个各有六列的单元格总共有 18 列,却能容纳一行 12 列。但事实是,网格系统将最后六列换行到另一行(注意,不是行)。

最好用段落和线条来对比流体网格系统。行就像段落,而不是线条。一个段落(行)可以包含多行。随着段落宽度(屏幕宽度)的减少,它将需要更多的行。只有当你要断两组句子(单元格组)的时候,你才真正需要另一段(行)。大多数人花了一些时间来欣赏流体网格系统的这一方面,因为许多流行的示例在固定网格而不是流体网格中显示行和列,因此将屏幕布局为多行。

接下来,让我们考虑一个稍微大一点的屏幕:平板电脑,在横向模式下。这个大小的属性是sm。让我们用一行中的所有三个单元格来填充屏幕宽度。我们必须为每个单元格使用四列的宽度,因此为这些单元格指定sm={4}。如果我们有更多的单元格,那么这也将包装成多行,但因为我们正好有三个,这将适合屏幕上的一行。

在像桌面这样的大屏幕上,我们可以让每个单元格继续占据四列,这不需要更多的属性规范。但是我认为如果表单控件伸展得太多,看起来会很笨拙,所以让我们使用md={3}lg={2}来减少单元格的宽度。这将导致较大屏幕上的尾随列未被占用。

Bootstrap 的网格系统通常以一个<Grid>开始,但是我们的整个内容已经被一个网格包装了,所以我们不需要另一个。我们可以直接添加一个<Row>,在其中我们可以添加<Col> s,它将保存每个FormGroupsButtonToolbar。我们先导入这两个组件。

...
import {
  ...
  Row, Col,
} from 'react-bootstrap';
...

现在,我们可以添加一个Row来替换其中的<div>和三个Col,这将包装表单组和按钮工具栏。

...
      <Row>
        <Col xs={6} sm={4} md={3} lg={2}>
          <FormGroup>
            ...
          </FormGroup>
        </Col>
        <Col xs={6} sm={4} md={3} lg={2}>
          <FormGroup>
            ...
          </FormGroup>
        </Col>
        <Col xs={6} sm={4} md={3} lg={2}>
          <ButtonToolbar>
          ...
          </ButtonToolbar>
        </Col>
      </Row>
...

但这将显示对齐中的一个问题。由于前两个单元格的高度包括标签,因此它大于按钮工具栏的高度。内容垂直居中对齐,按钮将出现在下拉列表和输入字段的上方。为了正确设置,我们还需要为按钮工具栏添加一个FormGroup,并使用&nbsp;添加一个空白标签。

...
          <FormGroup>
            <ControlLabel>&nbsp;</ControlLabel>
            <ButtonToolbar>
            ...
            </ButtonToolbar>
          </FormGroup>
...

发布过滤器的最终更改如清单 11-13 所示。(请注意,对缩进的更改不会突出显示。)

...
import {
  ...
  Row, Col,
} from 'react-bootstrap';
...

  render() {
    ...
    return (
      <div>
      <Row>
        <Col xs={6} sm={4} md={3} lg={2}>
          <FormGroup>
            <ControlLabel>Status:</ControlLabel>
            ...
          </FormGroup>
        </Col>
        <Col xs={6} sm={4} md={3} lg={2}>
          <FormGroup>
            <ControlLabel>Effort between:</ControlLabel>

            ...
          </FormGroup>
        </Col>
        <Col xs={6} sm={4} md={3} lg={2}>
          <FormGroup>
            <ControlLabel>&nbsp;</ControlLabel>
            <ButtonToolbar>
            ...
            </ButtonToolbar>
          </FormGroup>
        </Col>
      </Row>
      </div>
    );
  }

Listing 11-13ui/src/IssueFilter.jsx: Using the Grid System for Issue Filter

如果您测试这些更改,除了表单的布局之外,您应该不会发现任何行为上的差异。很小和小屏幕尺寸的截图分别如图 11-9 和图 11-10 所示。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig10_HTML.jpg

图 11-10

在小屏幕上用网格系统过滤问题

img/426054_2_En_11_Chapter/426054_2_En_11_Fig9_HTML.jpg

图 11-9

在一个很小的屏幕上用网格系统过滤问题

练习:网格系统

  1. 假设单元格较大,您需要单元格(a)在非常小的屏幕上,一个在另一个下面出现,(b)在小屏幕上,每行最多有两个单元格,(c)在中等大小的屏幕上,一起适合宽度,以及(d)在大屏幕上,一起占据三分之二的屏幕宽度。在这种情况下,列的宽度规格是多少?

  2. 虽然对于移动设备来说很棒,但是在桌面浏览器上输入控件看起来有点过大。怎么做才能让它们看起来小一点?提示:在 https://react-bootstrap.github.io/components/forms/ 查找表单的 React-Bootstrap 文档,并查找控制大小的属性。你可能会找到多个选项,所以选择一个你认为最好的。

本章末尾有答案。

嵌入式表单

有时我们希望表单控件彼此相邻,包括标签。这对于具有两个或三个输入的小型表单来说是非常理想的,这些输入可以全部放在一行中并且紧密相关。这种风格将适合问题添加形式。让我们也用标签替换占位符,使它们更明显,这意味着我们将不得不使用FormGroup s 和ControlLabel s,就像我们对过滤器表单所做的那样。

对于基于网格的表单,我们不必将控件或组包含在<Form>中,因为组的默认行为是垂直布局(一个在另一个下面,包括标签)。对于内嵌表单,我们需要一个带有inline属性的<Form>来包装表单控件。这也很方便,因为我们需要设置表单的其他属性:name 和 submit handler。

与基于网格的表单不同,内嵌表单不需要列和行。FormGroup元素可以一个接一个地放置。此外,按钮周围不需要一个FormGroup,如果没有给按钮一个ControlLabel,也没有对齐含义。至于元素之间的间距,我们需要使用{' '}在标签和控件之间以及表单组之间手动添加间距。

这些是将 Issue Add 表单转换为 Bootstrap inline 表单的唯一更改。这些如清单 11-14 所示。

...
import PropTypes from 'prop-types';

import {

  Form, FormControl, FormGroup, ControlLabel, Button,

} from 'react-bootstrap';

...

  render() {
    return (
      <form Form inline name="issueAdd" onSubmit={this.handleSubmit}>
        <input type="text" name="owner" placeholder="Owner" />
        <input type="text" name="title" placeholder="Title" />
        <button type="submit">Add</button>
        <FormGroup>
          <ControlLabel>Owner:</ControlLabel>
          {' '}
          <FormControl type="text" name="owner" />

        </FormGroup>
        {' '}
        <FormGroup>
          <ControlLabel>Title:</ControlLabel>
          {' '}
          <FormControl type="text" name="title" />
        </FormGroup>
        {' '}
        <Button bsStyle="primary" type="submit">Add</Button>
      </form>
      </Form>
    );
  }
...

Listing 11-14ui/src/IssueAdd.jsx: Changes for Conversion to an Inline Form

这可能是从问题列表页面中删除横向规则的好时机,因为表格和表单本身有明显的分离。页脚确实需要与页面的其余部分分开,所以让我们在页脚中添加一条水平线。这些变化显示在清单 11-15 和清单 11-16 中。

...
function Footer() {
  return (
    <small>
      <hr />
      <p className="text-center">
...

Listing 11-16ui/src/Page.jsx: Addition of Horizontal Rule Above the Footer

Listing 11-15ui/src/IssueList.jsx: Removal of Horizontal Rules

...
        <IssueTable
          ...
        >
        <hr />
        <IssueAdd createIssue={this.createIssue} />
        <hr />
...

现在测试应用时,您会发现这些变化只是视觉上的。务必确保功能没有改变。新屏幕的截图如图 11-11 所示。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig11_HTML.jpg

图 11-11

将问题添加表单作为内嵌表单

练习:内嵌表单

  1. 尝试在非常小的屏幕上查看新的IssueAdd表单。你看到了什么?关于 Bootstrap 和表单,它告诉了你什么?

  2. 假设您没有为控件更改使用标签。还需要一个FormGroup吗?尝试一下,尤其是在非常小的屏幕上。

  3. 这两个控件的宽度是相同的,似乎我们对此没有控制。属性似乎只影响高度。如果要显示更广的标题输入,可以怎么做?

本章末尾有答案。

水平形式

我们将探讨的下一种表单是水平表单,其中标签出现在输入的左侧,但是每个字段一个接一个地出现。通常,输入填充父容器直到右边缘,给它一个对齐的外观。让我们将问题编辑页面更改为使用水平表单,因为该表单有许多字段,这种表单将适合它。当我们这样做时,让我们也使用 Bootstrap 提供的验证状态来突出无效输入,而不是我们自己的基本方法来显示日期输入中的验证错误。

让我们首先导入我们将在IssueEdit.jsx中使用的新组件。

...
import { LinkContainer } from 'react-router-bootstrap';
import {
  Col, Panel, Form, FormGroup, FormControl, ControlLabel,
  ButtonToolbar, Button,
} from 'react-bootstrap';
...

为了布局一个水平的表单,我们需要horizontal属性,因此,我们需要用 Bootstrap 的<Form>替换一个普通的<form>,并设置这个属性。让我们也移动显示正在编辑的问题的 ID 的<h3>,并将整个表单包含在一个面板中,原始<h3>的内容形成面板的标题。让我们继续在表单后面的面板中显示验证消息。这一切都在IssueEdit组件的render()方法中。

...
    return (
      <Panel>
        <Panel.Heading>
          <Panel.Title>{`Editing issue: ${id}`}</Panel.Title>
        </Panel.Heading>
        <Panel.Body>
          <Form horizontal onSubmit={this.handleSubmit}>

           ...
          </Form>
          {validationMessage}
        </Panel.Body>
      </Panel>
    )
...

在表单中,我们可以为每个可编辑字段设置通常的FormGroup。在其中,我们将拥有控件标签和实际控件。但这还不是全部。我们还需要指定标签和输入将占用多少宽度。为此,我们需要将<ControlLabel><FormControl>包含在<Col> s 中,并指定列宽。因为我们希望它在大多数情况下填满屏幕,所以我们不会对不同的屏幕尺寸使用不同的宽度,只对小屏幕宽度使用一个规范,使用sm属性将标签和输入按一定比例分开。对于更大的屏幕宽度,网格系统将使用相同的比例。至于屏幕宽度很小,会导致它折叠成单列。让我们在两列之间选择 3-9 的分割。

例如,所有者字段可能是这样的:

...
            <FormGroup>
              <Col sm={3}>
                <ControlLabel>Owner</ControlLabel>
              </Col>
              <Col sm={9}>
                <FormControl name="owner" ... />
              </Col>
            </FormGroup>
...

用一个<Col>包围<FormControl>效果很好,但是对于一个<ControlLabel>,这并没有达到标签右对齐的预期效果。Bootstrap 程序文档中建议的方法是将<Col>componentClass设置为ControlLabel。这样做的效果是用一个ControlLabel和一个Col的组合类来呈现一个元素,而不是用一个<div>中的标签。

回想一下,我们也可以为FormInput指定自己的组件类。这让我们可以在需要的地方使用定制组件NumInputDateInputTextInput来代替常规的<input>。因此Owner字段的最终代码如下:

...
            <FormGroup>
              <Col componentClass={ControlLabel} sm={3}>Owner</Col>
              <Col sm={9}>
                <FormControl
                  componentClass={TextInput}
                  name="owner"
                  value={owner}
                  onChange={this.onChange}
                  key={id}
                />
              </Col>
            </FormGroup>
...

所有其他控件可以类似地编写,对于Status下拉列表的componentClassselect,对于所有者、标题和描述字段的TextInput,对于工作的NumInput,以及对于到期的DateInput。原始控件使用的所有其他属性都可以保留。有关这些其他控件的变更,请参考清单 11-17;为了简洁起见,我没有在这里提出这些变化。

对于创建的日期字段,React-Bootstrap 以组件FormControl.Static的形式提供了一个静态控件。让我们利用这一点。

...
                <FormControl.Static>
                  {created.toDateString()}
                </FormControl.Static>
...

对于 Submit 按钮,我们可以使用primary样式将其转换为 Bootstrap Button组件。但是通常的预期是在任何提交操作之后需要有一个取消操作。因为这不是一个对话框,所以让我们使用一个返回到问题列表的链接来代替取消操作。这可以通过围绕新的后退按钮的LinkContainer来实现,它被设计成一个链接。

...
                <ButtonToolbar>
                  <Button bsStyle="primary" type="submit">Submit</Button>
                  <LinkContainer to="/issues">
                    <Button bsStyle="link">Back</Button>
                  </LinkContainer>
                </ButtonToolbar>
...

现在,由于按钮不需要标签,我们可以像以前一样使用相同的技巧,在第一个单元格中提供一个空标签。但是更好的方法是,现在我们在网格中有了列,指定一个到列开始位置的偏移量。

...
            <FormGroup>
              <Col smOffset={3} sm={6}>
                <ButtonToolbar>
                ...
                </ButtonToolbar>
              </Col>
            </FormGroup>
...

我们可以保留下一个和上一个链接,但是让我们使用一个面板页脚来放置它们,就在面板主体的末尾。

...
        </Panel.Body>
        <Panel.Footer>
          <Link to={`/edit/${id - 1}`}>Prev</Link>
          {' | '}
          <Link to={`/edit/${id + 1}`}>Next</Link>
        </Panel.Footer>
...

Bootstrap 的表单控件支持以独特的方式显示无效的输入字段。为了实现这一点,validationState属性可以在任何FormGroup中使用。该属性的值为error会使标签和控件显示为红色,并在表单控件中显示一个表示相同内容的红色十字图标。

我们只有日期输入到期字段,它在此表单中可以有一个无效的状态。让我们使用状态变量invalidFields并在该对象中查找具有该字段名称的属性,以确定有效性。

...
            <FormGroup validationState={
              invalidFields.due ? 'error' : null
            }
            >
              <Col componentClass={ControlLabel} sm={3}>Due</Col>
...

清单 11-17 中显示了对IssueEdit.jsx文件的完整更改。为了可读性,显示了整个render()方法,而不是删除和添加的行,分别以删除线和粗体显示。唯一的其他变化是在进口,这是显示使用常规公约。

...
import { Link } from 'react-router-dom';
import { LinkContainer } from 'react-router-bootstrap';

import {

  Col, Panel, Form, FormGroup, FormControl, ControlLabel,
  ButtonToolbar, Button,

} from 'react-bootstrap';

...

    return (
      <Panel>
        <Panel.Heading>
          <Panel.Title>{`Editing issue: ${id}`}</Panel.Title>
        </Panel.Heading>
        <Panel.Body>
          <Form horizontal onSubmit={this.handleSubmit}>
            <FormGroup>
              <Col componentClass={ControlLabel} sm={3}>Created</Col>
              <Col sm={9}>
                <FormControl.Static>
                  {created.toDateString()}
                </FormControl.Static>
              </Col>
            </FormGroup>
            <FormGroup>
              <Col componentClass={ControlLabel} sm={3}>Status</Col>
              <Col sm={9}>
                <FormControl
                  componentClass="select"
                  name="status"
                  value={status}
                  onChange={this.onChange}

                >
                  <option value="New">New</option>
                  <option value="Assigned">Assigned</option>
                  <option value="Fixed">Fixed</option>
                  <option value="Closed">Closed</option>
                </FormControl>
              </Col>
            </FormGroup>
            <FormGroup>
              <Col componentClass={ControlLabel} sm={3}>Owner</Col>
              <Col sm={9}>
                <FormControl
                  componentClass={TextInput}
                  name="owner"
                  value={owner}
                  onChange={this.onChange}
                  key={id}
                />
              </Col>
            </FormGroup>
            <FormGroup>
              <Col componentClass={ControlLabel} sm={3}>Effort</Col>
              <Col sm={9}>
                <FormControl
                  componentClass={NumInput}
                  name="effort"
                  value={effort}
                  onChange={this.onChange}
                  key={id}
                />
              </Col>
            </FormGroup>
            <FormGroup validationState={
              invalidFields.due ? 'error' : null
            }
            >
              <Col componentClass={ControlLabel} sm={3}>Due</Col>
              <Col sm={9}>
                <FormControl
                  componentClass={DateInput}
                  onValidityChange={this.onValidityChange}
                  name="due"
                  value={due}
                  onChange={this.onChange}

                  key={id}
                />
                <FormControl.Feedback />
              </Col>
            </FormGroup>
            <FormGroup>
              <Col componentClass={ControlLabel} sm={3}>Title</Col>
              <Col sm={9}>
                <FormControl
                  componentClass={TextInput}
                  size={50}
                  name="title"
                  value={title}
                  onChange={this.onChange}
                  key={id}
                />
              </Col>
            </FormGroup>
            <FormGroup>
              <Col componentClass={ControlLabel} sm={3}>Description</Col>
              <Col sm={9}>
                <FormControl
                  componentClass={TextInput}
                  tag="textarea"
                  rows={4}
                  cols={50}
                  name="description"
                  value={description}
                  onChange={this.onChange}

                  key={id}
                />
              </Col>
            </FormGroup>
            <FormGroup>
              <Col smOffset={3} sm={6}>
                <ButtonToolbar>
                  <Button bsStyle="primary" type="submit">Submit</Button>
                  <LinkContainer to="/issues">
                    <Button bsStyle="link">Back</Button>
                  </LinkContainer>
                </ButtonToolbar>
              </Col>
            </FormGroup>
          </Form>
          {validationMessage}
        </Panel.Body>
        <Panel.Footer>
          <Link to={`/edit/${id - 1}`}>Prev</Link>
          {' | '}
          <Link to={`/edit/${id + 1}`}>Next</Link>
        </Panel.Footer>
      </Panel>
    );
  }
...

Listing 11-17ui/src/IssueEdit.jsx: render() Method Rewritten to Use a Bootstrap Horizontal Form

此时,如果您测试应用,您会发现日期字段没有填满屏幕的宽度。它看起来也与其他输入有很大不同。原因是我们根据验证状态将DateInput中的输入类设置为“null或“invalid”。Bootstrap 通常会为输入设置一个类,我们的设置,尤其是 null,会覆盖它。

我们在DateInput类中需要的是保留 Bootstrap 已经设置给<input>的类。一种选择是用this.props.className代替className。但是除了className之外,可能还有其他的属性被穿过。所以更安全的做法是使用其余的属性,并将它们传递给<input>元素。此外,我们不需要将类设置为invalid,因为 Bootstrap 的validationState会替换它。

清单 11-18 中显示了DateInput的变更。

...
  render() {
    const { valid, focused, value } = this.state;
    const { value: origValue, name } = this.props;
    const className = (!valid && !focused) ? 'invalid' : null;
    const { value: origValue, onValidityChange, ...props } = this.props;
    const displayValue = (focused || !valid) ? value
      : displayFormat(origValue);
    return (
      <input
        type="text"
        size={20}
        name={name}
        className={className}
        {...props}
        value={displayValue}
        placeholder={focused ? 'yyyy-mm-dd' : null}
        onFocus={this.onFocus}
    ...
 }
...

Listing 11-18ui/src/DateInput.jsx: Pass Through Class and Other Properties from Parent

更改之后,当您测试表单时,它将看起来像它应该的样子。该表单的屏幕截图如图 11-12 所示,其中包括验证消息和到期字段的错误指示,该字段显示红色边框和红色 x。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig12_HTML.jpg

图 11-12

作为水平 Bootstrap 表单的问题编辑页面

练习:水平形式

  1. 添加一个验证,使用 Bootstrap 的validationState检查标题是否为三个字符或更多,就像我们对 Due 字段所做的那样。这在效果上与 Due 字段的验证有何不同?视觉上有什么不同?为什么呢?

本章末尾有答案。

验证警报

Bootstrap 通过Alert组件提供了风格优美的警报。转换为 Bootstrap 式警报的第一个候选项是问题编辑页面中的验证消息。这可能看起来像页面的其余部分对齐和样式。也可能更微妙。因为表单域本身显示有问题,所以在用户单击 Submit 之前不需要显示错误消息。我们还会让用户在看到消息后将其删除。

React-Bootstraps 的<Alert>组件很好地解决了这个问题。它有不同风格的消息,如dangerwarning,它也有能力显示关闭图标。让我们将这个组件包含在IssueEdit组件的导入列表中。

...
import {
  ...
  ButtonToolbar, Button, Alert,
} from 'react-bootstrap';
...

然后,让我们使用这个组件来构造验证消息。

...
      validationMessage = (
        <div className="error">
        <Alert bsStyle="danger">
          Please correct invalid fields before submitting.
        </Alert>
        </div>
...

但是它的可见性需要由父组件来处理:消息应该基于IssueEdit中的状态变量有条件地显示。让我们添加这个状态变量。

...
  constructor() {
    ...
    this.state = {
      ...
      showingValidation: false,
    };
...

让我们使用这个新的状态变量来控制validationMessage的内容,同时初始化这个变量。

...
    const { invalidFields, showingValidation } = this.state;
    let validationMessage;
    if (Object.keys(invalidFields).length !== 0 && showingValidation) {
      validationMessage = (
        ...
      );
    }
...

关闭图标是<Alert>的一部分,为了让该图标使消息消失,我们必须传入一个修改可见性状态的处理程序。Alert组件接受一个名为onDismiss的回调来实现这一点。当用户单击关闭图标时,调用这个回调。现在让我们定义两个方法来切换验证消息可见性的状态,并将它们绑定到构造函数中的this(参见清单 11-19 中的bind语句)。

...
  showValidation() {
    this.setState({ showingValidation: true });
  }

  dismissValidation() {
    this.setState({ showingValidation: false });
  }
...

第二个方法可以作为onDismiss属性传递给Alert组件。

...
        <Alert bsStyle="danger" onDismiss={this.dismissValidation}>
...

现在,验证消息的可见性总是假的。正如我们已经决定的,让我们在用户点击提交时开始显示消息。这可以通过在提交处理程序中无条件调用showValidation()来完成。如果没有错误,则抑制验证消息。

...
  async handleSubmit(e) {
    e.preventDefault();
    this.showValidation();
    ...
  }
...

让我们也将验证消息从表单外部移动到一个新表单组中的 Submit 按钮,就在包含 Submit 按钮的FormGroup之后。我们将使用与按钮工具栏相同的策略来指定开始验证消息列的偏移量。

...
            <FormGroup>
              <Col smOffset={3} sm={9}>{validationMessage}</Col>
            </FormGroup>
          </Form>
          {validationMessage}
...

清单 11-19 中显示了对IssueEdit组件的完整更改。

...
import {
  ...
  ButtonToolbar, Button, Alert,
} from 'react-bootstrap';
...

  constructor() {
    ...
    this.state = {
      ...
      showingValidation: false,
    };
  }
...

  async handleSubmit(e) {
    e.preventDefault();
    this.showValidation();
    ...
  }
...

  async loadData() {
    ...
  }

  showValidation() {
    this.setState({ showingValidation: true });

  }

  dismissValidation() {
    this.setState({ showingValidation: false });
  }
...

  render() {
    ...
    const { invalidFields, showingValidation } = this.state;
    let validationMessage;
    if (Object.keys(invalidFields).length !== 0 && showingValidation) {
      validationMessage = (
        <div className="error">
        <Alert bsStyle="danger" onDismiss={this.dismissValidation}>
          Please correct invalid fields before submitting.
        </Alert>
        </div>
      );
    }
    ...
            <FormGroup>
              <Col smOffset={3} sm={9}>{validationMessage}</Col>
            </FormGroup>
          </Form>
          {validationMessage}

  }
...

Listing 11-19ui/src/IssueEdit.jsx: Showing Validation Using Bootstrap Alert Component

此时,我们可以去掉一些用红色字体显示这些错误的样式。对样式表的修改如清单 11-20 所示。

...
  <style>
    ...
    input.invalid {border-color: red;}
    div.error {color: red;}
    ....
  </style>
...

Listing 11-20ui/public/index.html: Removal of Old Styles

现在,如果您测试应用,您会发现 Due 字段中的错误被阻止提交。只有在您单击 Submit 后,才会显示程式化的错误消息,并且可以使用警告消息右上角的红色 X 来消除该错误消息。图 11-13 显示了一个问题编辑页面的屏幕截图,其中 Due 字段有一个错误。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig13_HTML.jpg

图 11-13

使用危险风格的 Bootstrap 警告的验证消息

烤面包

现在让我们看看结果消息和信息性警报,也就是操作成功和失败的报告。这些消息是为了不引人注目,所以让它们在几秒钟后自动消失,而不是让用户关闭它们。我们还将让消息覆盖页面,并像 Android 操作系统中的吐司消息一样过渡进出。

因为许多页面都需要显示这样的消息,所以让我们为此创建一个新的可重用的定制组件。让我们以 Android 操作系统的 Toast 消息命名这个新组件Toast。我们将在警报组件本身上建模接口:可见性将由父组件控制,父组件传递一个onDismiss属性,可以调用该属性来消除它。除了关闭图标的点击调用这个onDismiss回调之外,还会有一个定时器在到期时调用onDismiss()。它显示的消息可以被指定为组件的子组件。

下面是这个组件的一个例子,它可以放在父组件的 DOM 层次结构中的任何位置,因为它绝对位于父组件的布局之外。

...
        <Toast
          showing={this.state.showingToast}
          onDismiss={this.dismissToast}
          bsStyle="success"
        >
...

让我们开始在ui/src目录下名为Toast.jsx的新文件中实现Toast组件。我们将从 React 的基本导入和我们将使用的 React-Bootstrap 组件开始:AlertCollapse以及类本身的定义。

...
import React from 'react';
import { Alert, Collapse } from 'react-bootstrap';

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

render()方法中,我们将首先添加一个具有所需属性的警报,所有这些属性都是作为道具从父 Node 传入的。

...
  render() {
    const {
      showing, bsStyle, onDismiss, children,
    } = this.props;
    return (
          <Alert bsStyle={bsStyle} onDismiss={onDismiss}>
            {children}
          </Alert>
    );
  }
...

让我们将警告消息放在靠近窗口左下角的位置,覆盖任何其他 UI 元素。为此,我们可以使用样式position: fixed将警告包含在绝对定位的<div>中。

...
        <div style={{ position: 'fixed', bottom: 20, left: 20 }}>
          <Alert ... />
        </div>
...

为了显示和隐藏警报,我们将使用 React-Bootstrap 的Collapse组件。这个组件接受一个名为in的属性,这个属性决定了它的子元素是淡出in还是淡出。当设置为true时,子元素显示(淡入),当设置为false时,子元素隐藏(淡出)。为此,我们可以直接使用传入的属性showing

...
      <Collapse in={showing}>
        <div ... />
      </Collapse>
...

现在,让我们设置五秒钟后自动解散。因为我们期望 Toast 被构造为showing被设置为false,所以每当 Toast 被显示时,我们可以期待一个componentDidUpdate()调用。因此,在这个生命周期方法中,让我们添加一个计时器,并在它到期时调用onDismiss

...
componentDidUpdate() {
    const { showing, onDismiss } = this.props;
    if (showing) {
      setTimeout(onDismiss, 5000);
    }
  }
...

但是,即使用户离开了页面,计时器也可能触发,所以在组件被卸载时关闭计时器是个好主意。因此,让我们将计时器保存在一个名为dismissTimer的对象变量中,并在组件被卸载时清除这个计时器。在设置新的定时器之前,让我们也清除同一个定时器。

...
  componentDidUpdate() {
    ...
    if (showing) {
      clearTimeout(this.dismissTimer);
      this.dismissTimer = setTimeout(onDismiss, 5000);
    }
  }

  componentWillUnmount() {
    clearTimeout(this.dismissTimer);
  }
...

清单 11-21 中显示了Toast.jsx的完整源代码。

import React from 'react';
import { Alert, Collapse } from 'react-bootstrap';

export default class Toast extends React.Component {
  componentDidUpdate() {
    const { showing, onDismiss } = this.props;
    if (showing) {
      clearTimeout(this.dismissTimer);
      this.dismissTimer = setTimeout(onDismiss, 5000);
    }
  }

  componentWillUnmount() {
    clearTimeout(this.dismissTimer);
  }

  render() {
    const {
      showing, bsStyle, onDismiss, children,
    } = this.props;
    return (
      <Collapse in={showing}>
        <div style={{ position: 'fixed', bottom: 20, left: 20 }}>
          <Alert bsStyle={bsStyle} onDismiss={onDismiss}>
            {children}
          </Alert>
        </div>
      </Collapse>
    );
  }
}

Listing 11-21ui/src/Toast.jsx: New Component to Show a Toast Message

要使用Toast组件,我们必须对所有需要显示成功或错误消息的组件进行修改。但是在graphQLFetch.js中还有数据获取功能,显示任何错误的警告。因为这不是一个组件,所以让调用组件传入一个回调来显示错误。我们还将使这个回调成为可选的,以便调用者可以选择隐藏错误的显示,并通过查看返回值来处理它。我们也可以删除由于alert()函数调用的存在而禁用 ESLint 错误的注释,因为我们将不再有这些。graphQLFetch.js的变更如清单 11-22 所示。

...

/* eslint "no-alert": "off" */

...

export default async function
graphQLFetch(query, variables= {}, showError = null) {

  ...
    if (result.errors) {
        ...
        alert(`${error.message}:\n ${details}`);
        if (showError) showError(`${error.message}:\n ${details}`);
      } else if (showError) {
        alert(`${error.extensions.code}: ${error.message}`);
        showError(`${error.extensions.code}: ${error.message}`);
      }
    }
    ...
  } catch (e) {
    alert(`Error in sending data to server: ${e.message}`);
    if (showError) showError(`Error in sending data to server: ${e.message}`);
    ...
  }
...

Listing 11-22ui/src/graphQLFetch.js: Changes for Replacing Alerts with a Callback

接下来,让我们更改组件IssueDetailIssueEditIssueList来使用 Toast。这些组件的变化非常相似。让我们从导入组件和对状态的更改开始。我们需要一个可见性变量,一个消息变量,一个 Toast 类型变量(错误、成功、警告等)。).

...
import Toast from './Toast.jsx';
...
  constructor() {
    this.state = {
      ...
      toastVisible: false,
      toastMessage: ' ',
      toastType: 'success',
    };
    ...
  }
...

然后,让我们创建三个方便的函数:一个显示成功消息,一个显示错误消息,一个消除祝酒词。所有这些都是用 Toast 变量的新值来设置状态。

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

(这些必须绑定到this,我在这里没有明确显示它的代码。参见清单 11-23 。)

方法dismissToast()将被传递给Toast组件。我们可以用showSuccess()方法替换任何警报,例如在handleSubmit:IssueEdit组件中

...
    if (data) {
      this.setState({ issue: data.issueUpdate });
      alert('Updated issue successfully'); // eslint-disable-line no-alert
      this.showSuccess('Updated issue successfully');
    }
...

至于错误,我们可以将方法this.showError传递给graphQLFetch()的函数调用:

...
    const data = await graphQLFetch(query, { ... }, this.showError);
...

组件可以在任何地方呈现,但是让我们选择在 JSX 的最末端呈现,就在最后的结束标记之前。在IssueEdit的情况下,它将正好在面板的结束标记之前。

...
  render() {
    ...
    const { toastVisible, toastMessage, toastType } = this.state;

    return (
      <Panel>
        ...
        <Toast
          showing={toastVisible}
          onDismiss={this.dismissToast}
          bsStyle={toastType}
        >
          {toastMessage}
        </Toast>
      </Panel>
    );
...

下面的清单显示了对每个组件的实际更改,每个组件都有微小的变化。在IssueList组件中,除了前面的更改之外,让我们添加一条成功删除问题的成功消息。在IssueDetail组件中,不需要成功消息,因此不包括方法及其绑定。

清单 11-23 、清单 11-24 和清单 11-25 中显示了这些组件的变化。

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

import Toast from './Toast.jsx';

...

  constructor() {
    ...
    this.state = { issues: [] }
      issues: [],
      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 data = await graphQLFetch(query, vars, this.showError);
    ...
  }

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

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

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

  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 { issues } = this.state;
    const { toastVisible, toastType, toastMessage } = this.state;
    ...
    return (
      <React.Fragment>
        ...
        <Toast
          showing={toastVisible}
          onDismiss={this.dismissToast}
          bsStyle={toastType}
        >
          {toastMessage}
        </Toast>
      </React.Fragment>
    );
  }
...

Listing 11-25ui/src/IssueList.jsx: Changes for Including Toast Message

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

import Toast from './Toast.jsx';

...

  constructor() {
    this.state = { issue: {} };
      issue: {},
      toastVisible: false,
      toastMessage: ' ',
      toastType: 'info',
    };

    this.showError = this.showError.bind(this);
    this.dismissToast = this.dismissToast.bind(this);
  }
...
  componentDidUpdate(prevProps) {
    ...
  }

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

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

  async loadData() {
    ...
    const data = await graphQLFetch(query, { id }, this.showError);
    ...
  }

  render() {
    ...
    const { toastVisible, toastType, toastMessage } = this.state;
    return (
      <div>
        ...
        <Toast
          showing={toastVisible}
          onDismiss={this.dismissToast}

          bsStyle={toastType}
        >
          {toastMessage}
        </Toast>
      </div>
    );
  }
...

Listing 11-24ui/src/IssueDetail.jsx: Changes for Including Toast Component

...
import TextInput from './TextInput.jsx';

import Toast from './Toast.jsx';

...

  constructor() {
    ...
    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);
  }
  ...

  async handleSubmit(e) {
    ...
    const data = await graphQLFetch(query, { changes, id }, this.showError);
    if (data) {
      this.setState({ issue: data.issueUpdate });
      alert('Updated issue successfully'); // eslint-disable-line no-alert
      this.showSuccess('Updated issue successfully');
    }
  }

  async loadData() {
    ...
    const query = `query issue($id: Int!) {

      ...
    }`;

    const data = await graphQLFetch(query, { id }, this.showError);
    ...
  }
...

  dismissValidation() {
    ...
  }

  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 { toastVisible, toastMessage, toastType } = this.state;

    return (
      <Panel>
        ...
        <Toast
          showing={toastVisible}
          onDismiss={this.dismissToast}
          bsStyle={toastType}
        >
          {toastMessage}
        </Toast>
      </Panel>
    );
  }
...

Listing 11-23ui/src/IssueEdit.jsx: Changes for Using Toast Component

现在,我们已经去掉了 UI 代码中的所有警告,并用 Toast 消息替换它们。您可以通过创建或更新具有无效值的问题(如标题少于三个字符)来测试错误消息。您可以通过在编辑页面中保存问题并删除问题来测试成功提示消息。图 11-14 显示了保存更改后编辑页面中的一个成功 Toast 消息的截图。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig14_HTML.jpg

图 11-14

编辑页面中的 Toast 消息

模型

在本节中,我们将用一个模式对话框替换页面内组件IssueAdd,该对话框通过单击标题中的创建问题导航项来启动。这样,用户可以从应用的任何位置创建问题,而不仅仅是从问题列表页面。此外,当提交新问题时,我们将在问题编辑页面中显示新创建的问题,因为无论对话框是从哪里启动的,都可以这样做。

除了模态对话框,创建问题也可以是一个单独的页面。但是,当必需字段的数量很少时,模态工作得更好;用户可以快速创建问题,然后根据需要填写更多信息。

当一个模态被呈现时,它被呈现在保存页面的 DOM 的 main <div>之外。因此,就代码放置而言,它可以放置在组件层次结构中的任何位置。为了启动或关闭该模式,“创建问题”导航项目是控制组件。因此,让我们创建一个自包含的组件:它显示导航项,启动对话框并控制其可见性,创建问题,并在成功创建后转到问题编辑页面。让我们把这个新组件叫做IssueAddNavItem,并把它放在一个叫做IssueAddNavItem.jsx的文件中。

让我们首先将用于创建问题的NavItem从导航栏移动到这个新组件,并添加一个onClick()处理程序,通过调用方法showModal()来显示模态对话框,稍后我们将定义这个方法。

...
  render() {
    return (
      <React.Fragment>
        <NavItem onClick={this.showModal}>
          ...
        </NavItem>
      </React.Fragment>
    );
  }
...

接下来,因为模态组件可以放在任何地方,所以让我们把它添加在NavItem之后。模态对话框定义的根是Modal组件。这个组件需要两个重要的属性:showing,它控制模式对话框的可见性,以及一个onHide()处理程序,当用户单击十字图标关闭对话框时,它将被调用。我们将为showing定义一个状态变量来控制可见性。

...
    const { showing } = this.state;
    return (
      ...
        <NavItem onClick={this.showModal}>
          ...
        </NavItem>
        <Modal keyboard show={showing} onHide={this.hideModal}>

        </Modal>
    );
...

showModal()hideModal()方法只需要适当地设置状态变量。

...
  showModal() {
    this.setState({ showing: true });
  }

  hideModal() {
    this.setState({ showing: false });
  }
...

Modal组件中,让我们使用标题(Modal.Header)来显示模态的标题。属性可以用来显示一个十字图标,点击它可以取消对话框。

...
        <Modal keyboard show={showing} onHide={this.hideModal}>
          <Modal.Header closeButton>
            <Modal.Title>Create Issue</Modal.Title>
          </Modal.Header>
        </Modal>
...

然后,在主体中,让我们添加一个带有两个字段 Title 和 Owner 的垂直表单(默认)。这将是两个FormGroups,就像最初的 Add inline 表单一样,但是它们之间没有任何间隔。

...
          <Modal.Body>
            <Form name="issueAdd">
              <FormGroup>
                <ControlLabel>Title</ControlLabel>
                <FormControl name="title" autoFocus />
              </FormGroup>
              <FormGroup>
                <ControlLabel>Owner</ControlLabel>
                <FormControl name="owner" />
              </FormGroup>
            </Form>
          </Modal.Body>
...

我们将使用模态的页脚(Modal.Footer)来显示一个按钮工具栏,其中 Submit 和 Cancel 按钮分别作为主按钮和链接按钮。我们让提交按钮 click 调用方法handleSubmit(),取消按钮隐藏模态对话框。

...
          <Modal.Footer>
            <ButtonToolbar>
              <Button
                type="button"
                bsStyle="primary"
                onClick={this.handleSubmit}
              >
                Submit
              </Button>
              <Button bsStyle="link" onClick={this.hideModal}>Cancel</Button>
            </ButtonToolbar>
          </Modal.Footer>
...

handleSubmit()方法中,我们需要结合读取表单值(从文件IssueAdd.jsx中)和通过调用 Create API(从IssueList.jsx中)提交值这两个功能。当用户点击提交时,我们还需要关闭模式对话框。如果成功,我们将通过将编辑页面的链接推送到历史记录来显示问题编辑页面。

...
  async handleSubmit(e) {
    e.preventDefault();
    this.hideModal();
    const form = document.forms.issueAdd;
    const issue = {
      owner: form.owner.value,
      title: form.title.value,
      due: new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * 10),
    };
    const query = `mutation issueAdd($issue: IssueInputs!) {
      issueAdd(issue: $issue) {
        id
      }
    }`;

    const data = await graphQLFetch(query, { issue }, this.showError);
    if (data) {
      const { history } = this.props;
      history.push(`/edit/${data.issueAdd.id}`);
    }
  }
...

为了处理graphQLFetch调用中的错误,我们需要显示一条 Toast 消息。因此,我们将像前面几节一样定义 Toast 状态变量和一个showError()方法。新文件IssueAddNavItem.jsx的内容,包括 Toast 消息的添加,如清单 11-26 所示。

import React from 'react';
import { withRouter } from 'react-router-dom';
import {
  NavItem, Glyphicon, Modal, Form, FormGroup, FormControl, ControlLabel,
  Button, ButtonToolbar, Tooltip, OverlayTrigger,
} from 'react-bootstrap';

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

class IssueAddNavItem extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      showing: false,
      toastVisible: false,
      toastMessage: '',
      toastType: 'success',
    };
    this.showModal = this.showModal.bind(this);
    this.hideModal = this.hideModal.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
    this.showError = this.showError.bind(this);
    this.dismissToast = this.dismissToast.bind(this);
  }

  showModal() {
    this.setState({ showing: true });
  }

  hideModal() {
    this.setState({ showing: false });

  }

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

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

  async handleSubmit(e) {
    e.preventDefault();
    this.hideModal();
    const form = document.forms.issueAdd;
    const issue = {
      owner: form.owner.value,
      title: form.title.value,
      due: new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * 10),
    };
    const query = `mutation issueAdd($issue: IssueInputs!) {
      issueAdd(issue: $issue) {
        id
      }
    }`;

    const data = await graphQLFetch(query, { issue }, this.showError);
    if (data) {
      const { history } = this.props;
      history.push(`/edit/${data.issueAdd.id}`);
    }
  }

  render() {
    const { showing } = this.state;
    const { toastVisible, toastMessage, toastType } = this.state;
    return (
      <React.Fragment>
        <NavItem onClick={this.showModal}>
          <OverlayTrigger
            placement="left"
            delayShow={1000}

            overlay={<Tooltip id="create-issue">Create Issue</Tooltip>}
          >
            <Glyphicon glyph="plus" />
          </OverlayTrigger>
        </NavItem>
        <Modal keyboard show={showing} onHide={this.hideModal}>
          <Modal.Header closeButton>
            <Modal.Title>Create Issue</Modal.Title>
          </Modal.Header>
          <Modal.Body>
            <Form name="issueAdd">
              <FormGroup>
                <ControlLabel>Title</ControlLabel>
                <FormControl name="title" autoFocus />
              </FormGroup>
              <FormGroup>
                <ControlLabel>Owner</ControlLabel>
                <FormControl name="owner" />
              </FormGroup>
            </Form>
          </Modal.Body>
          <Modal.Footer>
            <ButtonToolbar>
              <Button
                type="button"
                bsStyle="primary"
                onClick={this.handleSubmit}
              >
                Submit
              </Button>
              <Button bsStyle="link" onClick={this.hideModal}>Cancel</Button>

            </ButtonToolbar>
          </Modal.Footer>
        </Modal>
        <Toast
          showing={toastVisible}
          onDismiss={this.dismissToast}
          bsStyle={toastType}
        >
          {toastMessage}
        </Toast>
      </React.Fragment>
    );
  }
}

export default withRouter(IssueAddNavItem);

Listing 11-26ui/src/IssueAddNavItem.jsx: New File for Adding an Issue

为了使用这个新组件,我们需要用这个新组件的实例替换Page.jsx中的NavItem。对Page.jsx的更改如清单 11-27 所示。

...
import {
  ...
  MenuItem, Glyphicon, Tooltip, OverlayTrigger,
  Grid,
}

import IssueAddNavItem from './IssueAddNavItem.jsx';

...
function NavBar() {
  ...
      <Nav pullRight>
        <NavItem>
          ...
        </NavItem>
        <IssueAddNavItem />
        ...
      </Nav>
  ...
}
...

Listing 11-27ui/src/Page.jsx

IssueList组件中,我们可以删除IssueAddcreateIssue函数的渲染,因为问题是直接从模态中创建的。IssueList.jsx的变更如清单 11-28 所示。

...
import IssueTable from './IssueTable.jsx';

import IssueAdd from './IssueAdd.jsx';

...
  constructor() {
    ...
    this.createIssue = this.createIssue.bind(this);
    ...
  }
...

  async createIssue(issue) {
    ...
  }

  render() {
    ...
        <IssueAdd createIssue={this.createIssue} />

    ...
  }
...

Listing 11-28ui/src/IssueList.jsx: Changes to Remove IssueAdd and createIssue

完成这些更改后,如果您点击导航栏右侧的+图标,您应该会看到如图 11-15 所示的模态对话框。您应该能够使用此对话框创建新问题,如果成功,您应该会看到新创建问题的编辑页面。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig15_HTML.jpg

图 11-15

“创建问题模式”对话框

摘要

在 MERN 堆栈中向应用添加样式和主题与任何其他堆栈没有什么不同,因为重要的部分是 CSS 以及各种浏览器如何处理样式。它们不会因选择的堆栈而有所不同。这个领域的先驱 Bootstrap 实现了浏览器独立性和开箱即用的响应行为。React-Bootstrap 取代了处理 Bootstrap 元素的独立 JavaScript 代码,使自包含组件成为可能。

我们可以使用 Material-UI 或任何其他框架来实现所需要的东西,但是从本章中可以看出,如果需要的话,如何设计自己风格的可重用 UI 组件。如果你看一下位于 https://react-bootstrap.github.io/getting-started/introduction 的 React-Bootstrap 和位于 http://getbootstrap.com/docs/3.3/ 的 Bootstrap 本身的文档,以了解这两个库提供的组件种类,那也是很好的。

此时,除了一些高级特性之外,应用可能看起来是完整的。但是,如果应用需要搜索引擎机器人能够自然地索引页面,它将需要能够直接从服务器提供完全构建的页面,因为它们会出现在浏览器中。这是因为搜索引擎通常不会在它们抓取的页面中运行 JavaScript,对于 SPA 来说,这是在浏览器中构建页面的关键。

在下一章中,您将学习如何在服务器上构建 HTML 并响应客户端,更重要的是,如何在客户端和 UI 服务器上使用相同的代码库来实现这一点。

练习答案

练习:导航栏

  1. 仅仅使用fixedTop属性将导致导航栏覆盖内容的顶部。要解决这个问题,您需要按照 Bootstrap 文档中的建议给body标签添加一个填充。

    通常,除了 React-Bootstrap 文档之外,您还需要参考 Bootstrap 文档,因为 React-Bootstrap 基于 Bootstrap。当您这样做时,请记住选择 3.3 版文档,而不是最新的 Bootstrap 4 版文档。

练习:面板

  1. 属性控制面板的初始状态是展开还是关闭。将该属性设置为true(或仅指定该属性)将具有在浏览器刷新时显示过滤器打开的效果。为了使该行为依赖于过滤器的存在,您可以检查 location 的搜索属性是否为空字符串,以计算该属性的值,如下面的代码片段所示:

    ...
        const hasFilter = location.search !== ' ';
        return (
          <React.Fragment>
            <Panel defaultExpanded={hasFilter}>
          ...
    ...
    
    

练习:网格系统

  1. 如果单元格更大,规格xs={12} sm={6} md={4} lg={3}将会发挥最佳作用。在非常小和小的屏幕上,你会看到多行,在中等和大的屏幕上,单元格将适合一行。

  2. 为了让控件看起来更小,你可以在FormGroup s 上使用bsSize="small"属性。它也可以在FormControl s 上使用,但是在FormGroup上使用它也会影响标签。但是这不适用于按钮——我们必须为每个按钮指定属性。

练习:内嵌表单

  1. 在一个非常小的屏幕上,控件看起来像一个默认的窗体,标签和控件一个在另一个下面。React-Bootstrap 表单组件具有进行这种转换的媒体查询,就像网格系统中的列一样。

  2. 不,一个表单组并不真的需要显示一个接一个的内嵌表单控件。但是如果没有一个窗体组,在一个非常小的屏幕上,控件之间靠得太近,视觉上没有吸引力。因此,即使不使用标签,最好用一个FormGroup将控件括起来。

  3. 要指定精确的宽度,必须使用内嵌样式,比如style={{ width: 300 }}。如果没有宽度规格,控制项会在非常小的萤幕上填满萤幕的宽度。对于宽度,它采用所有屏幕尺寸上指定的宽度。实际上,如果我们设置了宽度,最好在所有控件上设置尺寸,而不是在某些控件上。

练习:水平形式

  1. The way to add a validation indication is using the following property in the FormGroup corresponding to the Title field:

    ...
                <FormGroup validationState={title.length < 3 ? 'error' : null}>
    ...
    
    

    尽管这确实显示了错误(在控件周围使用红色边框,标签为红色字体),但并没有阻止提交。这是因为我们没有让TextInput处理长度错误并通知IssueEdit组件,以便它可以更新invalidFields

    从视觉上看,您会发现在 Title 字段输入无效的情况下,红色的 X 是不存在的。原因与我们对该部分进行更改之前对DateInput的原因相同:我们没有通过 Bootstrap 可能添加到组件中的道具,如class