MERN 技术栈高级教程(五)
十、React 表单
用户输入是任何 web 应用的重要组成部分,问题跟踪器应用也不例外。我们创建了一个表单和用户输入来创建一个新问题。但是它是非常初级的,并且它没有演示在 React 中应该如何处理表单。
在这一章,我们将开始接受更多的用户输入。我们将把硬编码的过滤器转换成更灵活的用户输入,然后用一个表单填充编辑页面。最后,我们将添加从问题列表页面删除问题的功能,尽管这不一定是一个表单。
为了能够做到这一切,我们还必须修改后端 API 来支持这些功能。我们将修改 List API 以获得更多的过滤器选项,并且我们将创建新的更新和删除 API。因此,我们将完成所有 CRUD 操作的实现。
受控组件
声明式编程面临的挑战之一是表单输入中的用户交互,尤其是当表单输入包含用于显示模型初始值的值时。如果像传统的 HTML 代码一样,一个文本的值<input>被设置为一个字符串,这意味着这个值总是那个字符串,因为它是这样声明的。另一方面,如果允许用户编辑来更改该值,任何重新渲染都会破坏用户更改。
我们在使用IssueAdd组件和表单时没有这个问题,因为表单没有任何初始值,也就是说,它只执行接收用户输入的功能。因此,组件的内部状态可能不受其父组件IssueAdd的控制。每当需要输入值时,例如当用户点击Add按钮时,可以通过使用传统的 HTML 函数查看其值来确定。
为了能够在输入中显示一个值,它必须由父 Node 通过状态变量或 props 变量控制。这可以通过将输入值设置为 state 或 props 变量来实现。因此,输入将直接反映该值,呈现表单的 React 组件也将控制表单中随后的用户输入会发生什么。其值由 React 以这种方式控制的输入表单元素被称为受控组件。
如果您还记得,在上一章中,我们推迟了下拉列表中当前活动过滤器的显示。我们现在开始吧。让我们将下拉列表的值设置为状态过滤器的值。为此,我们将使用URLSearchParams并在IssueFilter组件的render()期间提取其当前值。清单 10-1 中显示了对此的更改。
...
import React from 'react';
import URLSearchParams from 'url-search-params';
...
render() {
const { location: { search } } = this.props;
const params = new URLSearchParams(search);
return (
...
<select value={params.get('status') || ''} onChange={this.onChangeStatus}>
...
Listing 10-1.ui/src/IssueFilter.jsx: Status Filter as a Controlled Component
此时,如果您测试应用,您会发现,与以前不同,刷新显示的是过滤器的当前值,而不是默认值All。
表单中的受控组件
状态过滤器现在是一个简单的受控组件,但这并不适用于所有情况。现在让我们添加更多的过滤器(我们很快就会添加)。我们将需要一个过滤器的表单,我们将让用户进行更改,然后使用应用按钮应用它们。让我们从添加一个带有应用处理程序的应用按钮开始。
...
<select value={params.get('status') || ''} onChange={this.onChangeStatus}>
...
</select>
{' '}
<button type="button" onClick={this.applyFilter}>Apply</button>
...
现在,在onChangeStatus方法中,我们需要删除将新过滤器推入历史的代码,因为这将是applyFilter方法的一部分:
...
onChangeStatus(e) {
const status = e.target.value;
const { history } = this.props;
history.push({
pathname: '/issues',
search: status ? `?status=${status}` : '',
});
}
...
此时(您可以忽略 ESLint 错误,因为我们将很快填充该方法所需的代码),如果您测试应用,您会发现您不能更改下拉列表的值!这是因为 select 的值仍然是原始值。在该值更改之前,下拉列表无法显示新的状态。
许多其他框架(例如 Angular)提供的解决方案是开箱即用的双向绑定。组件不仅绑定到状态中的值,反之亦然。任何用户输入也会自动改变状态变量。
但是在 React 中,单向数据流很重要,它不支持双向绑定作为库的一部分。为了让用户的更改流回表单输入组件,必须在输入中设置新值。为了获得新的值,必须捕获onChange()事件,它将事件作为参数,作为事件的一部分,我们可以获得用户选择的新值。
这也意味着,我们需要的不是 URL 参数,而是输入值的存储,可以更改它以反映下拉列表中的新值。州是储存这种价值的理想场所。当用户更改值时,可以使用onChange()事件处理程序中的setState()用新值更新状态变量,以便它作为输入值反映回来。
让我们首先创建这个状态变量,并在构造函数中将其初始化为 URL 值。
...
constructor({ location: { search } }) {
super();
const params = new URLSearchParams(search);
this.state = {
status: params.get('status') || “,
};
this.onChangeStatus = this.onChangeStatus.bind(this);
}
...
然后,让我们使用这个状态变量作为在render()期间下拉输入的值。
...
render() {
const { status } = this.state;
...
<select value={status} onChange={this.onChangeStatus}>
...
作为处理onChange的一部分,我们可以将状态变量设置为新值,作为事件参数的一部分提供给处理程序,如event.target.value。
...
onChangeStatus(e) {
this.setState({ status: e.target.value });
}
...
现在,您会发现您可以更改下拉列表的值。更重要的是,值总是作为状态的一部分可用,所以要访问当前值,我们需要做的就是访问this.state.status。让我们在applyFilter中这样做,并使用历史来推送新的状态过滤器(我们从下拉菜单的onChange处理程序中移除了它),然后在构造函数中将这个新方法绑定到this。
...
constructor({ location: { search } }) {
...
this.onChangeStatus = this.onChangeStatus.bind(this);
this.applyFilter = this.applyFilter.bind(this);
}
...
applyFilter() {
const { status } = this.state;
const { history } = this.props;
history.push({
pathname: '/issues',
search: status ? `?status=${status}` : “,
});
}
...
此时,您会发现 Apply 按钮的工作原理是更改 URL,从而应用一个新的过滤器。但是还有一个小问题。当应用一个过滤器并通过Link改变过滤器时,例如通过点击导航栏中的问题列表,新的过滤器不会被反映。这是因为当链接被点击时,只有组件的属性被改变。不会再次构造该组件,也不会修改其状态。
为了让新的过滤器反映链接被点击的时间,我们需要挂钩到一个生命周期方法,告诉我们一个属性已经改变,并再次显示过滤器。我们将使用与之前相同的方法来寻找属性的变化:componentDidUpdate。而且,显示过滤器只需要根据搜索参数在状态中设置新值,就像在构造函数中一样。
...
constructor() {
...
}
componentDidUpdate(prevProps) {
const { location: { search: prevSearch } } = prevProps;
const { location: { search } } = this.props;
if (prevSearch !== search) {
this.showOriginalFilter();
}
}
onChangeStatus(e) {
...
}
showOriginalFilter() {
const { location: { search } } = this.props;
const params = new URLSearchParams(search);
this.setState({
status: params.get('status') || “,
});
}
...
最后,让我们也指出用户已经选择了一个新的过滤器,但是还没有应用新的过滤器的状态之间的区别。同时,让我们给用户一个重置过滤器的选项,以便用户可以看到显示的列表正在使用的原始过滤器。我们可以通过添加一个Reset按钮来做到这一点,当有任何更改时,该按钮将被启用,单击Reset按钮,将显示原始的过滤器。为此,我们需要引入一个名为changed的状态变量,我们将基于这个变量禁用按钮。
...
render() {
const { status, changed } = this.state;
return (
...
<button type="button" onClick={this.applyFilter}>Apply</button>
{' '}
<button
type="button"
onClick={this.showOriginalFilter}
disabled={!changed}
>
Reset
</button>
...
状态变量需要在onChange内设置为true,在构造函数中设置为false,当原始过滤器通过重置再次显示时。此外,由于方法showOriginalFilter现在是从一个事件中调用的,我们必须将它绑定到this。
...
constructor({ location: { search } }) {
...
this.state = {
status: params.get('status') || “,
changed: false,
};
...
this.showOriginalFilter = this.showOriginalFilter.bind(this);
}
...
onChangeStatus(e) {
this.setState({ status: e.target.value, changed: true });
}
...
showOriginalFilter() {
...
this.setState({
status: params.get('status') || “,
changed: false,
});
}
所有这些变化都显示在清单 10-2 中。
...
constructor({ location: { search } }) {
super();
const params = new URLSearchParams(search);
this.state = {
status: params.get('status') || “,
changed: false
,
};
this.onChangeStatus = this.onChangeStatus.bind(this);
this.applyFilter = this.applyFilter.bind(this);
this.showOriginalFilter = this.showOriginalFilter.bind(this);
}
...
componentDidUpdate(prevProps) {
const { location: { search: prevSearch } } = prevProps;
const { location: { search } } = this.props;
if (prevSearch !== search) {
this.showOriginalFilter();
}
}
onChangeStatus(e) {
const status = e.target.value;
const { history } = this.props;
history.push({
pathname: '/issues',
search: status ? `?status=${status}` : “,
});
this.setState({ status: e.target.value, changed: true });
}
showOriginalFilter() {
const { location: { search } } = this.props;
const params = new URLSearchParams(search);
this.setState({
status: params.get('status') || “,
changed: false,
});
}
applyFilter() {
const { status } = this.state;
const { history } = this.props;
history.push({
pathname: '/issues',
search: status ? `?status=${status}` : “,
});
}
render() {
const { location: { search } } = this.props;
const params = new URLSearchParams(search);
const { status, changed } = this.state;
return (
...
<select value={params.get('status') || “} onChange={this.onChangeStatus}>
<select value={status} onChange={this.onChangeStatus}>
...
</select>
{' '}
<button type="button" onClick={this.applyFilter}>Apply</button>
{' '}
<button
type="button"
onClick={this.showOriginalFilter}
disabled={!changed}
>
Reset
</button>
</div>
);
...
Listing 10-2.ui/src/IssueFilter.jsx: Changes for Using Controlled Components in Forms
注意
尽管我们不遗余力地确保所有代码清单的准确性,但在本书付印之前,可能会有一些错别字甚至更正没有被收入书中。所以,总是依赖 GitHub 库( https://github.com/vasansr/pro-mern-stack-2 )作为所有代码清单的经过测试的和最新的源代码,尤其是当某些东西不能按预期工作时。
通过这些更改,当您测试应用时,您会发现只有在单击 Apply 按钮时才会应用过滤器(而不是在下拉列表中选择新值)。此外,刷新浏览器将保留正在显示的过滤器(如果有),并且当对过滤器进行任何更改时,重置按钮将被启用。您可以通过点按“重设”按钮恢复到原始过滤器。图 10-1 显示了新更改的过滤表单的截图。
图 10-1。
新的过滤器表单
更多过滤器
现在我们有了过滤器的表单,我们可以添加更多的方法来过滤问题列表。实际应用中一个有用的过滤器是 Assignee 字段上的过滤器。但是从学习表单的角度来看,这并不有趣,因为它是一个文本字段,非常简单——我们必须添加一个文本输入,在它的onChange中,我们必须更新一个状态变量,并在过滤器中使用它。
一个更有趣的过滤字段是非文本字段,这不是那么简单。因此,让我们在“工作”字段上添加一个过滤器,因为这是一个数字。为此,我们需要两个字段,一个最小值和一个最大值来过滤,这两个值都是可选的。首先,让我们更改 API 来实现这个过滤器,并使用 Playground 测试它。
让我们首先改变模式,向issueList API 添加两个参数,都是整数,称为effortMin和effortMax。schema.graphql的变更如清单 10-3 所示。
type Query {
about: String!
issueList(status: StatusType): [Issue!]!
issueList(
status: StatusType
effortMin: Int
effortMax: Int
): [Issue!]!
issue(id: Int!): Issue!
}
Listing 10-3.api/schema.graphql: Changes for More Filter Options
处理新值不像处理状态那样简单,因为我们必须检查大于和小于,而不是相等比较。MongoDB 过滤器的effort属性只有在两个选项都存在的情况下才能被创建,然后$gte和$lte选项必须被设置,如果没有定义的话。issue.js的变更如清单 10-4 所示。
async function list(_, { status, effortMin, effortMax }) {
...
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;
}
...
}
Listing 10-4.api/issue.js: Changes for Filter on Effort
为了测试issueList API 中的新过滤器,您可以使用带有命名查询的 Playground,如下所示:
query issueList(
$status: StatusType
$effortMin: Int
$effortMax: Int
) {
issueList(
status: $status
effortMin: $effortMin
effortMax: $effortMax
) {
id
title
status
owner
effort
}
}
您可以在底部的查询变量部分为effortMin和effortMax给出不同的值来测试它。
练习:更多过滤器
-
使用问题跟踪器应用中的添加表单添加一些问题。现在,如果你在操场上运行一个以
effortMin为 0 的查询,你会发现添加的文档并没有返回。对于effortMin的任何值都是如此。为什么? -
如果您希望返回所有未定义工作的文档,而不管查询是什么,您将如何修改过滤器?提示:在
https://docs.mongodb.com/manual/reference/operator/query/or查找 MongoDB$or查询操作符。
本章末尾有答案。
打字输入
在本节中,我们将更改 UI,为工作过滤器添加两个输入。由于这两个输入只需要接受数字,我们将为用户的击键添加一个过滤器,以便只接受数字。
但在此之前,让我们修改IssueList,以便在加载数据时使用新的过滤器值。更改包括从 URL 的搜索参数中获取两个额外的过滤器参数,并在修改后的 GraphQL 查询中使用它们来获取问题列表。因为来自 URL 的值是字符串,所以必须使用parseInt()将它们转换成整数值。对此的更改如清单 10-5 所示。
...
async loadData() {
...
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) {
const query = `query issueList(
$status: StatusType
$effortMin: Int
$effortMax: Int
) {
issueList (status: $status) {
issueList(
status: $status
effortMin: $effortMin
effortMax: $effortMax
) {
...
}
}`;
...
Listing 10-5.ui/src/IssueList.jsx: Using Effort Filters in Issue List
此时,您可以通过在 URL 中键入过滤器参数来测试这些更改的效果。下一步是为IssueFilter组件中的新过滤字段的输入添加两个状态变量。就这么办吧。
...
constructor({ location: { search } }) {
...
this.state = {
status: params.get('status') || “,
effortMin: params.get('effortMin') || “,
effortMax: params.get('effortMax') || “,
changed: false,
};
...
}
...
同时我们也在showOriginalFilter里加上这些,也是类似的改动。(这些变化微不足道,为了简洁起见,这里没有突出显示。如果需要,参考清单 10-6 。)注意,我们在状态中使用字符串来表示这些值,它们实际上是数字。它给我们带来的便利是,当操作或读取 URL 参数时,我们不需要在数字和字符串之间进行转换。
现在,让我们在过滤器表单中为这些值添加输入字段。我们将在IssueFilter组件的状态下拉列表后添加两个文本类型的<input>字段。我们将使用状态中相应变量的值。我们还将为这两个onChange方法设置onChange处理程序。
...
render() {
const { status, changed } = this.state;
const { effortMin, effortMax } = this.state;
return (
...
<select value={status} onChange={this.onChangeStatus}>
...
</select>
{' '}
Effort between:
{' '}
<input
size={5}
value={effortMin}
onChange={this.onChangeEffortMin}
/>
{' - '}
<input
size={5}
value={effortMax}
onChange={this.onChangeEffortMax}
/>
...
...
到目前为止,这些变化与我们对 status 下拉菜单所做的非常相似。但是这是一个数字输入,我们必须验证这个值确实是一个数字。相反,让我们防止用户输入非数字字符。通常,我们会在onChange处理程序中将状态变量设置为event.target.value。相反,我们将测试结果文本是否可以转换为数字,如果可以,我们将放弃更改,而不是设置状态变量。这里是用于effortMin字段的onChange处理程序,它通过使用正则表达式来匹配只包含数字字符的输入(\d)来实现这一点。
...
onChangeEffortMin(e) {
const effortString = e.target.value;
if (effortString.match(/^\d*$/)) {
this.setState({ effortMin: e.target.value, changed: true });
}
}
...
让我们为onChangeEffortMax添加一个类似的处理程序,并将这些方法绑定到构造函数中的this。(参考清单 10-6 了解这一简单变化。)
最后,我们可以使用applyFilter中的状态变量来设置历史中的新位置。因为有更多的变量,所以让我们使用URLSearchParams来构造查询字符串,而不是使用普通的字符串模板。
...
applyFilter() {
const { status, effortMin, effortMax } = this.state;
const { history } = this.props;
history.push({
pathname: '/issues',
search: status ? `?status=${status}` : “,
});
const params = new URLSearchParams();
if (status) params.set('status', status);
if (effortMin) params.set('effortMin', effortMin);
if (effortMax) params.set('effortMax', effortMax);
const search = params.toString() ? `?${params.toString()}` : “;
history.push({ pathname: '/issues', search });
}
...
在清单 10-6 中显示了在IssueFilter组件中添加这两个过滤字段的一整套更改。
...
constructor({ location: { search } }) {
...
status: params.get('status') || “,
effortMin: params.get('effortMin') || “,
effortMax: params.get('effortMax') || “,
...
this.onChangeStatus = this.onChangeStatus.bind(this);
this.onChangeEffortMin = this.onChangeEffortMin.bind(this);
this.onChangeEffortMax = this.onChangeEffortMax.bind(this);
}
...
onChangeStatus(e) {
...
}
onChangeEffortMin(e) {
const effortString = e.target.value;
if (effortString.match(/^\d*$/)) {
this.setState({ effortMin: e.target.value, changed: true });
}
}
onChangeEffortMax(e) {
const effortString = e.target.value
;
if (effortString.match(/^\d*$/)) {
this.setState({ effortMax: e.target.value, changed: true });
}
}
...
showOriginalFilter() {
...
status: params.get('status') || “,
effortMin: params.get('effortMin') || “,
effortMax: params.get('effortMax') || “,
...
}
applyFilter() {
const { status, effortMin, effortMax } = this.state;
const { history } = this.props;
history.push({
pathname: '/issues',
search: status ? `?status=${status}` : “,
});
const params = new URLSearchParams();
if (status) params.set('status', status);
if (effortMin) params.set('effortMin', effortMin);
if (effortMax) params.set('effortMax', effortMax);
const search = params.toString() ? `?${params.toString()}` : “;
history.push({ pathname: '/issues', search });
}
render() {
const { status, changed } = this.state;
const { effortMin, effortMax } = this.state
;
...
</select>
{' '}
Effort between:
{' '}
<input
size={5}
value={effortMin}
onChange={this.onChangeEffortMin}
/>
{' - '}
<input
size={5}
value={effortMax}
onChange={this.onChangeEffortMax}
/>
...
}
...
Listing 10-6.ui/src/IssueFilter.jsx: Changes for Adding Effort Filters
有了这组更改,您将能够使用各种过滤器组合来测试应用。URL 栏中也应该可以看到过滤器值。要清除过滤器,您可以单击导航栏中的问题列表链接。图 10-2 中显示了应用了最大努力 10 点的新滤镜的截图。
图 10-2。
显示工作过滤器的问题列表
练习:打字输入
-
假设我们不将
effortMin和effortMax的值转换成整数,也就是说,我们使用 GraphQL 查询变量中的字符串值。你预计会发生什么?试试看,确认你的答案。 -
尝试使用
<input type="number">而不是默认的文本类型。在不同的浏览器上测试,比如 Chrome、Safari 和 Firefox。键入可能有效的数字字符,如。(点)和 e .你观察到了什么?为什么呢?提示:在变更处理程序中添加一些console.log语句,并观察日志。
本章末尾有答案。
编辑表单
我们一直有一个编辑页面的占位符。现在您已经了解了组件,尤其是受控组件,让我们试着为IssueEdit.jsx中的编辑页面创建一个完整的表单,在用户可以更改的输入字段中显示问题的所有字段。我们还会有一个提交按钮,但是我们还不会处理表单的提交。在我们实现了一个 API 来更新一个现有的问题之后,我们将把这个问题留给下面的部分。
让我们从定义这个组件的状态开始。至少,我们需要存储每个输入的当前值,它对应于正在编辑的问题的字段。在构造函数中,让我们为问题定义一个空对象。(为了简洁起见,我省略了显而易见的新代码,比如 imports。请参考下面的清单,了解完整的更改。下面的代码片段是用来解释的。)
...
export default class IssueEdit extends React.Component {
constructor() {
super();
this.state = {
issue: {},
};
...
我们将用从服务器获取的问题替换空问题。让我们在一个名为loadData()的方法中使用issue API 来异步加载数据。对此的 GraphQL 查询很简单;它接受 ID 作为参数(来自 props ),并指定需要返回所有可能的字段。
...
async loadData() {
const query = `query issue($id: Int!) {
issue(id: $id) {
id title status owner
effort created due description
}
}`;
const { match: { params: { id } } } = this.props;
const data = await graphQLFetch(query, { id });
}
...
但是因为所有输入字段的内容都是字符串,所以状态字段也需要是字符串。我们不能直接使用 API 调用的结果。因此,在加载数据后,我们必须将问题字段的自然数据类型转换为字符串。此外,我们需要为所有可选字段添加一个null检查,并使用空字符串作为值。如果 API 失败(由空的data表示),我们将在状态中使用一个空的问题。
...
if (data) {
const { issue } = data;
issue.due = issue.due ? issue.due.toDateString() : “;
issue.effort = issue.effort != null ? issue.effort.toString() : “;
issue.owner = issue.owner != null ? issue.owner : “;
issue.description = issue.description != null ? issue.description : “;
this.setState({ issue });
} else {
this.setState({ issue: {});
}
}
...
现在,我们可以编写render()方法了。在这种方法中,每个字段的值都可以设置为状态中的相应值。例如,owner输入字段看起来像这样:
...
const { issue: { owner } } = this.state;
<input value={owner} />
...
但是在组件已经被构造并且loadData()已经返回数据的期间,我们将发布对象作为一个空对象。如果给定 ID 的问题不存在,也是如此。为了处理这两种情况,让我们检查问题对象中是否存在一个id字段,并避免呈现表单。如果 props 中的id字段无效,我们将显示一条错误消息,指出该 id 不存在任何问题。如果没有,我们将假设页面正在完成loadData(),并在render()方法中返回 null。
...
const { issue: { id } } = this.state;
const { match: { params: { id: propsId } } } = this.props;
if (id == null) {
if (propsId != null) {
return <h3>{`Issue with ID ${propsId} not found.`}</h3>;
}
return null;
}
...
如果这两个条件不匹配,我们可以呈现表单。请注意,我们使用的是二倍等于而不是三倍等于,它匹配任何看起来像 null 的东西,包括未定义的。我们将使用一个有两列的表,在第一列显示字段名的标签,输入(或 ID 和创建日期的只读标签)作为值。除了所有字段之外,该表单还需要一个提交按钮和一个提交处理程序,我们将命名为handleSubmit()。
此外,为了测试在加载新的问题对象时表单是否正常工作,而无需转到另一个页面或手动更改 URL,让我们添加上一个和下一个问题对象的链接。因为这只是一个测试,当下一个或上一个 id 不是有效 ID 时,我们不会禁用链接;我们将让页面显示一个错误。这些链接可以添加到表单的末尾。
...
return (
<form onSubmit={this.handleSubmit}>
<h3>{`Editing issue: ${id}`}</h3>
<table>
<tbody>
<tr>
<td>Created:</td>
<td>{created.toDateString()}</td>
</tr>
...
<tr>
<td />
<td><button type="submit">Submit</button></td>
</tr>
</tbody>
</table>
<Link to={`/edit/${id - 1}`}>Prev</Link>
{' | '}
<Link to={`/edit/${id + 1}`}>Next</Link>
</form>
);
...
与过滤器表单一样,我们还需要一个针对每个输入的onChange事件处理程序。但是由于这个表单中的字段数量太多,这可能会变得单调乏味和重复。相反,让我们利用事件的目标有一个名为name的属性,它将反映表单中输入字段的名称。让我们使用问题对象中的字段名称作为输入的名称。然后,让我们为所有的输入使用一个通用的onChange事件处理程序。例如,owner输入行现在看起来像这样:
...
<tr>
<td>Owner:</td>
<td>
<input
name="owner"
value={owner}
onChange={this.onChange}
/>
</td>
</tr>
...
对于其余的输入字段,请参考清单 10-7 。
并且,在onChange()方法中,我们将从事件的目标中获取字段的名称,用它来设置issue对象中的属性值(需要一个来自当前状态的副本),并设置新的状态。注意,不建议在到达新状态时直接使用this.state,因为当其他setState调用已经发出但尚未生效时,它可能无法准确反映真实的当前值。推荐的方法是给setState方法提供一个回调,该方法接受前一个状态并返回一个新状态。下面是考虑到这些问题的onChange()事件处理程序。
...
onChange(event) {
const { name, value } = event.target;
this.setState(prevState => ({
issue: { ...prevState.issue, [name]: value },
}));
}
...
注意
我们使用 ES2015+ spread操作符...到传播发布对象的值,就好像它们都是单独提到的,像{ id: prevState.issue.id, title: prevState.issue.title }等。与Object.assign()相比,这是一种更简单的复制对象的方式。然后用name作为变量名的值的属性来覆盖被扩展的属性。
最后,我们需要生命周期方法componentDidMount()和componentDidUpdate()来加载数据。同样,在handleSubmit()方法中,我们现在只在控制台上显示问题的内容。清单 10-7 显示了IssueEdit文件的完整列表,包括这些额外的方法和一些输入字段的修饰属性。为了简洁起见,没有显示删除的代码。
import React from 'react';
import { Link } from 'react-router-dom';
import graphQLFetch from './graphQLFetch.js';
export default class IssueEdit extends React.Component {
constructor() {
super();
this.state = {
issue: {},
};
this.onChange = this.onChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
componentDidMount() {
this.loadData();
}
componentDidUpdate(prevProps) {
const { match: { params: { id: prevId } } } = prevProps;
const { match: { params: { id } } } = this.props;
if (id !== prevId) {
this.loadData();
}
}
onChange(event) {
const { name, value } = event.target;
this.setState(prevState => ({
issue: { ...prevState.issue, [name]: value },
}));
}
handleSubmit(e) {
e.preventDefault();
const { issue } = this.state;
console.log(issue); // eslint-disable-line no-console
}
async loadData() {
const query = `query issue($id: Int!) {
issue(id: $id) {
id title status owner
effort created due description
}
}`;
const { match: { params: { id } } } = this.props;
const data = await graphQLFetch(query, { id });
if (data) {
const { issue } = data;
issue.due = issue.due ? issue.due.toDateString() : “;
issue.effort = issue.effort != null ? issue.effort.toString() : “;
issue.owner = issue.owner != null ? issue.owner : “;
issue.description = issue.description != null ? issue.description : “;
this.setState({ issue });
} else {
this.setState({ issue: {} });
}
}
render() {
const { issue: { id } } = this.state;
const { match: { params: { id: propsId } } } = this.props;
if (id == null) {
if (propsId != null) {
return <h3>{`Issue with ID ${propsId} not found.`}</h3>;
}
return null;
}
const { issue: { title, status } } = this.state;
const { issue: { owner, effort, description } } = this.state;
const { issue: { created, due } } = this.state;
return (
<form onSubmit={this.handleSubmit}>
<h3>{`Editing issue: ${id}`}</h3>
<table>
<tbody>
<tr>
<td>Created:</td>
<td>{created.toDateString()}</td>
</tr>
<tr>
<td>Status:</td>
<td>
<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>
</select>
</td>
</tr>
<tr>
<td>Owner:</td>
<td>
<input
name="owner"
value={owner}
onChange={this.onChange}
/>
</td>
</tr>
<tr>
<td>Effort:</td>
<td>
<input
name="effort"
value={effort}
onChange={this.onChange}
/>
</td>
</tr>
<tr>
<td>Due:</td>
<td>
<input
name="due"
value={due}
onChange={this.onChange}
/>
</td>
</tr>
<tr>
<td>Title:</td>
<td>
<input
size={50}
name="title"
value={title}
onChange={this.onChange}
/>
</td>
</tr>
<tr>
<td>Description:</td>
<td>
<textarea
rows={8}
cols={50}
name="description"
value={description}
onChange={this.onChange}
/>
</td>
</tr>
<tr>
<td />
<td><button type="submit">Submit</button></td>
</tr>
</tbody>
</table>
<Link to={`/edit/${id - 1}`}>Prev</Link>
{' | '}
<Link to={`/edit/${id + 1}`}>Next</Link>
</form>
);
}
}
Listing 10-7.ui/src/IssueEdit.jsx: New Contents for Showing an Edit Form
现在可以通过单击问题列表页面中任何问题的编辑链接来测试编辑页面。您可以看到字段值反映了数据库中保存的内容。单击 Submit 将在控制台上显示编辑后的值,但这些值将是字符串而不是自然数据类型。编辑页面截图如图 10-3 所示。
图 10-3。
编辑页面
练习:编辑页面
- 如果我们不将字符串字段中的空值转换为空字符串,会发生什么?通过删除检查 null 并为 description 字段分配一个空字符串的那一行,自己尝试一下。对缺少描述字段而不是值(甚至是空字符串)的问题执行此操作。
本章末尾有答案。
专用输入组件
虽然我们通过将所有的onChange()处理程序合并到一个处理程序中节省了一些重复的代码,但是很明显这种方法还有改进的余地。
-
在处理非字符串数据类型时,当需要验证值时(例如,检查完成日期是否在今天之前),必须将其转换为自然数据类型。在将修改后的问题发送到服务器之前,需要进行相同的转换。
-
如果有多个相同类型(数字或日期)的输入,则需要重复每个输入的转换。
-
输入允许用户输入任何内容,并且不会拒绝无效的数字或日期。我们已经发现 HTML5 输入类型没有多大帮助,而且由于
onChange处理程序是一个通用的处理程序,您不能为不同的输入类型添加掩码。
理想情况下,我们希望表单的状态以自然数据类型(数字、日期等)存储字段。).我们还希望共享所有的数据类型转换例程。解决所有这些问题的一个好办法是为非字符串输入制作可重用的 UI 组件,这些组件在其onChange处理程序中发出自然数据类型。我们可以很好地使用一些很棒的包,比如提供这些 UI 组件的 react-numeric-input 和 react-datepicker。但是为了理解如何构建这些 UI 组件,让我们创建我们自己的极简组件。
我们将首先为数字创建一个简单的 UI 组件,进行简单的验证和转换。然后,我们将为日期创建一个更复杂的 UI 组件,它能做更多的事情,比如让用户知道值是否以及何时无效。
在所有这些组件中,我们将采用分离状态的方法——在这种情况下,只要用户没有编辑该组件,该组件就是受控的,它唯一的功能就是显示当前值。当用户开始编辑时,我们将使它成为不受控制的组件。在这种状态下,父对象中的值将不会更新,并且两个值(当前值和已编辑值)将会分离。一旦用户完成编辑,如果值有效,这两个值将同步返回。
从另一个角度来看,专门化的组件是不受控制的,但是实际的<input>元素是受控制的。也就是说,在专门化的组件中,我们将有一个状态变量来控制输入元素中的值。这种方法有助于我们处理暂时无效的值,在许多情况下,从一个有效值转换到另一个有效值时需要用到这些值。对于简单的数字,这种需求可能不是那么明显。但是当你不得不处理像小数和日期这样的数据类型时,就会出现用户还没打完,中间值无效的情况。如果用户在输入被判断为有效或无效之前完成输入,这对提高可用性有很大帮助。
数字输入
我们将创建的第一个专用输入组件是用于数字输入的。我们将在编辑页面中使用它来代替普通的<input>元素。让我们称这个组件为NumInput,并在ui/src/目录下使用一个名为NumInput.jsx的新文件。
让我们首先定义接收字符串并转换为数字的转换函数,反之亦然。作为其中的一部分,我们将使用一个空字符串来对应数字的空值。
...
function format(num) {
return num != null ? num.toString() : “;
}
function unformat(str) {
const val = parseInt(str, 10);
return Number.isNaN(val) ? null : val;
}
...
在unformat()函数中,如果字符串不代表数字,我们返回 null。因为我们将检查用户击键中的有效字符,所以只有当字符串为空时才会出现非数字输入,所以这已经足够好了。
接下来,在组件的构造函数中,在将作为 props 传入的值转换为字符串之后,让我们设置一个状态变量(我们将使用它作为<input>元素的值)。
...
constructor(props) {
...
this.state = { value: format(props.value) };
}
...
在输入的onChange()中,我们将检查包含有效数字的输入,如果是,则设置状态,就像我们在过滤器表单中所做的那样。
...
onChange(e) {
if (e.target.value.match(/^\d*$/)) {
this.setState({ value: e.target.value });
}
}
...
为了使更改在父对象中生效,我们必须调用父对象的onChange()。我们不会将此作为组件的onChange()方法的一部分;相反,当输入失去焦点时,我们将调用父 Node 的onChange()。input 元素的onBlur()属性可以用来处理焦点丢失。在调用父 Node 的onChange()时,我们将自然数据类型中的值作为第二个参数传递。这样,如果需要的话,我们可以让父 Node 处理onChange()的原始事件(第一个参数)。
...
onBlur(e) {
const { onChange } = this.props;
const { value } = this.state;
onChange(e, unformat(value));
}
...
在render()方法中,我们将只呈现一个值被设置为状态变量的<input>元素以及组件类的onChange()和onBlur()处理程序。这些方法必须绑定到构造函数中的this,因为它们是事件处理程序。此外,我们将复制父元素可能想要作为实际的<input>元素的props的一部分提供的所有其他属性(例如,size属性)。让我们使用 spread 操作符无缝地做到这一点,使用语法{...this.props}。
清单 10-8 中这个新文件的完整清单显示了render()功能以及onBlur()和onChange()的绑定。
import React from 'react';
function format(num) {
return num != null ? num.toString() : “;
}
function unformat(str) {
const val = parseInt(str, 10);
return Number.isNaN(val) ? null : val;
}
export default class NumInput extends React.Component {
constructor(props) {
super(props);
this.state = { value: format(props.value) };
this.onBlur = this.onBlur.bind(this);
this.onChange = this.onChange.bind(this);
}
onChange(e) {
if (e.target.value.match(/^\d*$/)) {
this.setState({ value: e.target.value });
}
}
onBlur(e) {
const { onChange } = this.props;
const { value } = this.state;
onChange(e, unformat(value));
}
render() {
const { value } = this.state;
return (
<input
type="text"
{...this.props}
value={value}
onBlur={this.onBlur}
onChange={this.onChange}
/>
);
}
}
Listing 10-8.ui/src/NumInput.jsx: New Specialized Input Component for Numbers
现在,让我们在问题编辑页面中使用这个新的 UI 组件。第一个变化是在IssueEdit组件中将<input>替换为<NumInput>。
...
<td>Effort:</td>
<td>
<input NumInput
...
然后,让我们更改onChange()处理程序,以包含组件可能作为第二个参数发送给我们的自然数据类型中的值。但是我们也有常规的 HTML 组件,它们不会提供第二个参数作为自然值。因此,需要检查是否提供了该值。如果没有提供,我们可以使用事件本身的字符串值。
...
onChange(event, naturalValue) {
const { name, value: textValue } = event.target;
const value = naturalValue === undefined ? textValue : naturalValue;
...
}
...
经过这些更改后,如果您测试应用,只要您从问题列表导航到任何编辑页面,它似乎都可以正常工作。但是,如果您使用“下一个/上一个”按钮,您会发现“工作”字段中的值不会改变,相反,它会保留原始问题的工作值。如果显示的第一个问题没有结果,它将保持空白。
这是因为在构造组件时,我们只将 props 中的值复制到NumInput组件的状态。此后,当使用下一个/上一个按钮时,组件的属性会改变,但状态保持不变,因为旧的组件被重新使用。我们有以下选择来解决这个问题:
-
我们可以挂钩生命周期方法
componentWillReceiveProps()(不推荐)或getDerivedStateFromProps()来重新初始化状态。但是这些方法也可以在道具没有变化,但是父对象由于某种原因被重新渲染时调用。我们可以检查 props 值的变化,但是当下一个/上一个问题具有相同的努力值时怎么办? -
我们可以使用生命周期方法
componentDidUpdate()来替换状态。但是正如 ESLint 错误所建议的那样,在这个方法中同步设置状态并不是一个好主意。 -
我们可以捕获
onFocus()事件来设置编辑状态。否则,我们可以显示转换成字符串的 props 值。但是,即使这样,在输入有焦点的情况下,当显示的问题被另一个问题替换时,这种方法也不起作用。(如果正在显示的问题作为计时器的一部分被更改,如在幻灯片中,则会发生这种情况。) -
当一个新的问题被加载时,我们可以重新绘制页面。例如,这可以通过在
loadData()正在进行时引入“正在加载”状态并呈现消息或 null 而不是表单来实现。当问题对象改变时,这将强制重新构建组件。但是,当加载新的问题时,这会导致闪烁,因为整个表单会暂时消失。
这些选项中的任何一个都可以在一些假设或一些变通办法下工作。但是让我们使用推荐的方法来处理这种情况。本质上需要的是一种用新的初始属性构造组件的方法。最好的方法是给组件分配一个key属性,当加载一个新的问题时,这个属性会改变。React 使用此属性来指示如果键不同,则不能重用组件对象;必须建造一个新的。
因为问题的 ID 是惟一的,所以我们也可以将它用作输入组件的键。让我们开始吧。此外,我们现在可以为 effort 字段删除空字符串的替换,因为空字符串将由NumInput组件无缝处理。随着这些变化,对IssueEdit组件的最终修改如清单 10-9 所示。
...
import graphQLFetch from './graphQLFetch.js';
import NumInput from './NumInput.jsx';
...
onChange(event, naturalValue) {
const { name, value: textValue } = event.target;
const value = naturalValue === undefined ? textValue : naturalValue;
...
}
...
async loadData() {
...
if (data) {
issue.due = issue.due ? issue.due.toDateString() : '';
issue.effort = issue.effort != null ? issue.effort.toString() : '';
...
}
...
render() {
...
<td>Effort:</td>
<td>
<input NumInput
name="effort"
value={effort}
onChange={this.onChange}
key={id}
/>
...
}
...
Listing 10-9.ui/src/IssueEdit.jsx: Changes for Using NumInput
现在,如果您测试该应用,您应该能够编辑“工作”字段,并看到当您单击“下一个/上一个”时,该值根据问题对象而变化。另外,当您单击 Submit 时,您应该能够看到 issue 对象中的工作值确实是一个数字(该值周围没有引号)。
日期输入
在数字输入专用组件中,我们不必担心用户输入的有效性,因为我们防止了用户输入任何无效值。对于日期输入,我们不能这样做,因为有效性不能完全由日期中允许的字符来决定。例如,尽管允许使用所有数字,但像 999999 这样的数字不是有效的日期。
本质上,对于一个日期,只有当用户输入完日期时,才能确定其有效性。从输入元素失去焦点可以被用作编辑已经完成的信号。因此,在onBlur()处理程序中,我们必须检查用户输入的日期的有效性,然后通知父 Node 新值有效性的变化(如果有的话),以及新值是否有效。为了通知父 Node 新的有效性,让我们使用一个新的可选回调函数onValidityChange()。让我们把焦点状态和有效性保存在新的状态变量中,叫做focused和valid。下面是新的onBlur()方法,包括所有这些:
...
onBlur(e) {
const { value, valid: oldValid } = this.state;
const { onValidityChange, onChange } = this.props;
const dateValue = unformat(value);
const valid = value === “ || dateValue != null;
if (valid !== oldValid && onValidityChange) {
onValidityChange(e, valid);
}
this.setState({ focused: false, valid });
if (valid) onChange(e, dateValue);
}
...
注意,我们允许一个空字符串作为有效日期,以及任何其他可以使用unformat()方法转换成日期对象的字符串,我们将只使用Date(string)构造函数。
让我们也将日期的显示格式和可编辑格式分开。为了便于显示,可以根据方法toDateString()将日期转换成的语言环境来显示日期字符串。但是在编辑时,让我们强迫用户输入明确的YYYY-MM-DD格式。因此,我们将有两个功能,一个用于显示,另一个用于编辑,而不是像在NumInput中那样只有一个format()功能。
...
function displayFormat(date) {
return (date != null) ? date.toDateString() : “;
}
function editFormat(date) {
return (date != null) ? date.toISOString().substr(0, 10) : “;
}
function unformat(str) {
const val = new Date(str);
return Number.isNaN(val.getTime()) ? null : val;
}
...
在onChange()方法中,我们将检查有效字符,它们只是数字和破折号(-)字符。所有其他字符将被禁止。我们将在这次检查中使用的正则表达式是/^[\d-]*$/。
在render()方法中,如果用户输入的值是无效的,或者用户正在编辑它,让我们按原样显示它。否则,让我们显示从日期的原始值转换的显示格式或可编辑格式。
...
const displayValue = (focused || !valid) ? value
: displayFormat(origValue);
render() {
<input
...
value={displayValue}
...
}
...
让我们也使用 CSS 类来表示无效值,但是只有当输入不处于焦点状态时,也就是说,用户没有在编辑它。
...
const className = (!valid && !focused) ? 'invalid' : null;
...
清单 10-10 中显示了DateInput组件的代码,以及所有这些附加内容、元素的外观属性和完整的构造函数。
import React from 'react';
function displayFormat(date) {
return (date != null) ? date.toDateString() : “;
}
function editFormat(date) {
return (date != null) ? date.toISOString().substr(0, 10) : “;
}
function unformat(str) {
const val = new Date(str);
return Number.isNaN(val.getTime()) ? null : val;
}
export default class DateInput extends React.Component {
constructor(props) {
super(props);
this.state = {
value: editFormat(props.value),
focused: false,
valid: true
,
};
this.onFocus = this.onFocus.bind(this);
this.onBlur = this.onBlur.bind(this);
this.onChange = this.onChange.bind(this);
}
onFocus() {
this.setState({ focused: true });
}
onBlur(e) {
const { value, valid: oldValid } = this.state;
const { onValidityChange, onChange } = this.props;
const dateValue = unformat(value);
const valid = value === “ || dateValue != null;
if (valid !== oldValid && onValidityChange) {
onValidityChange(e, valid);
}
this.setState({ focused: false, valid });
if (valid) onChange(e, dateValue);
}
onChange(e) {
if (e.target.value.match(/^[\d-]*$/)) {
this.setState({ value: e.target.value });
}
}
render() {
const { valid, focused, value } = this.state;
const { value: origValue, name } = this.props;
const className = (!valid && !focused) ? 'invalid' : null;
const displayValue = (focused || !valid) ? value
: displayFormat(origValue);
return (
<input
type="text"
size={20}
name={name}
className={className}
value={displayValue}
placeholder={focused ? 'yyyy-mm-dd' : null}
onFocus={this.onFocus}
onBlur={this.onBlur}
onChange={this.onChange}
/>
);
}
}
Listing 10-10.ui/src/DateInput.jsx: New File for the DateInput Component
为了在IssueEdit中使用这个新组件,让我们将due字段改为一个DateInput组件。此外,我们必须添加一个新的方法,用于在一个名为invalidFields的状态变量中存储每个输入的有效性状态。让我们使用一个对象,对于每个设置为值true的无效字段,它都有一个条目。如果字段有效,我们将删除该属性,这样就可以方便地检查任何无效字段的存在。
...
onValidityChange(event, valid) {
const { name } = event.target;
this.setState((prevState) => {
const invalidFields = { ...prevState.invalidFields, [name]: !valid };
if (valid) delete invalidFields[name];
return { invalidFields };
});
}
...
在构造函数和方法loadData()中,我们必须将状态变量invalidFields设置为一个空对象,以便为正在加载的任何新问题初始化它。
在render()方法中,我们现在可以添加一个新变量来计算显示任何无效字段存在的消息。只有当存在任何无效字段时,我们才会初始化这条消息,这可以通过查看invalidFields状态变量的长度来计算。让我们也使用一个名为error的类来强调错误消息。
...
const { invalidFields } = this.state;
let validationMessage;
if (Object.keys(invalidFields).length !== 0) {
validationMessage = (
<div className="error">
Please correct invalid fields before submitting.
</div>
);
}
...
如果在编辑页面中有任何无效的输入,我们可以在表单中使用这个元素来显示消息。
...
</table>
{validationMessage}
<Link to={`/edit/${id - 1}`}>Prev</Link>
...
清单 10-11 中显示了这些变化,以及一些更加修饰性的变化,比如将方法绑定到IssueEdit组件中的this。
...
import NumInput from './NumInput.jsx';
import DateInput from './DateInput.jsx';
...
constructor() {
...
this.state = {
...
invalidFields: {},
};
...
this.onValidityChange = this.onValidityChange.bind(this);
}
...
onValidityChange(event, valid) {
const { name } = event.target;
this.setState((prevState) => {
const invalidFields = { ...prevState.invalidFields, [name]: !valid };
if (valid) delete invalidFields[name];
return { invalidFields };
});
}
...
async loadData() {
...
if (data) {
...
issue.due = issue.due ? issue.due.toDateString() : “;
...
this.setState({ issue, invalidFields: {} });
} else {
this.setState({ issue: {}, invalidFields: {} })
;
}
}
...
render() {
...
if (id == null) {
...
}
const { invalidFields } = this.state;
let validationMessage;
if (Object.keys(invalidFields).length !== 0) {
validationMessage = (
<div className="error">
Please correct invalid fields before submitting.
</div>
);
}
...
<input DateInput
name="due"
value={due}
onChange={this.onChange}
onValidityChange={this.onValidityChange}
key={id}
/>
...
</table>
{validationMessage}
<Link to={`/edit/${id - 1}`}>Prev</Link>
...
}
...
Listing 10-11.ui/src/IssueEdit: Changes to Use the New DateInput Component
我们需要对样式表进行一些修改,以突出显示错误消息(比如用红色字体),并以不同的方式显示有错误的输入(比如用红色边框代替普通边框)。清单 10-12 中显示了对index.html的这些更改。
...
<style>
...
input.invalid {border-color: red;}
div.error {color: red;}
</style>
...
Listing 10-12.ui/public/index.html: Style Changes for Error Messages and Error Inputs
样式更改需要刷新浏览器,因为 HMR 不处理对index.html的更改。一旦完成,您就可以测试新的日期输入字段了。当您输入一个有效值并单击 Submit 时,您将看到实际的日期对象被存储并显示在控制台中。对于所有无效的值,您应该看到一个红色边框的输入以及一个红色的错误消息。对于这些无效输入,您将看到用户在单击 Submit 时输入的原始值或任何以前的有效值。
文本输入
文本输入可能看起来没有必要,因为不需要进行验证或转换。但是让组件处理输入字段的空值会非常方便。否则,对于每个可选的文本字段,我们将需要处理 null 检查,并在加载数据时使用空字符串。
因此,非常类似于NumInput组件,让我们创建一个TextInput组件,有一些不同。format()和unformat()的存在只是为了与空值进行相互转换。在onChange()方法中,我们没有有效用户输入的掩码:任何输入都是允许的。最后,为了处理 HTML 元素名称的变化(我们可以让textarea和input,两者都处理文本数据),我们不要在组件中硬编码元素标签,而是让我们把它作为可选的tag属性传入,我们可以默认为input。为了能够做到这一点,我们将不得不退回到React.createElement()方法,而不是使用 JSX,因为标签名是一个变量。
清单 10-13 中显示了TextInput组件的完整源代码。
import React from 'react';
function format(text) {
return text != null ? text : “;
}
function unformat(text) {
return text.trim().length === 0 ? null : text;
}
export default class TextInput extends React.Component {
constructor(props) {
super(props);
this.state = { value: format(props.value) };
this.onBlur = this.onBlur.bind(this);
this.onChange = this.onChange.bind(this);
}
onChange(e) {
this.setState({ value: e.target.value });
}
onBlur(e) {
const { onChange } = this.props;
const { value } = this.state;
onChange(e, unformat(value));
}
render() {
const { value } = this.state;
const { tag = 'input', ...props } = this.props;
return React.createElement(tag, {
...props,
value,
onBlur: this.onBlur,
onChange: this.onChange
,
});
}
}
Listing 10-13.ui/src/TextInput.jsx: New Text Input Component
在IssueEdit组件中,我们可以将所有文本输入元素替换为TextInput,将描述元素替换为TextInput,并将标签的属性设置为textarea。我们将不得不使用key属性来确保在从编辑一个问题切换到另一个问题时组件被重构。最后,我们可以删除所有 null 到空字符串的转换,并在IssueEdit组件的状态下加载问题。
清单 10-14 显示了对IssueEdit的最后一组更改。
...
import DateInput from './DateInput.jsx';
import TextInput from './TextInput.jsx';
...
async loadData() {
...
if (data) {
const { issue } = data;
issue.owner = issue.owner != null ? issue.owner : “;
issue.description = issue.description != null ? issue.description : “;
this.setState({ issue, invalidFields: {} });
} else {
this.setState({ issue: {}, invalidFields: {} });
}
this.setState({ issue: data ? data.issue : {}, invalidFields: {} });
}
...
render() {
...
<td>Owner:</td>
<td>
<input TextInput
name="owner"
...
key={id}
</td>
...
<td>Title:</td>
<td>
<input TextInput
name="title"
...
key={id}
</td>
...
<td>Description:</td>
<td>
<textarea TextInput
tag="textarea"
...
key={id}
</td>
...
}
...
Listing 10-14.ui/src/IssueEdit.jsx: Changes for Using TextInput Component
现在,您可以测试所有的文本输入,并查看当单击 Submit 时,用户输入的空字符串是否被转换为null值,反之亦然:数据库中的空值应该在 UI 中显示为空字符串。
更新 API
现在我们已经有了编辑页面的用户界面,让我们准备将编辑过的问题保存到数据库中。当然,我们需要一个更新 API,这就是我们将在本节中实现的。可以通过两种不同的方式进行更新:
-
更新文档中的一个或多个字段:这可以使用 MongoDB
update命令并使用$set操作符来设置字段的新值。 -
用新值替换整个文档:这类似于创建一个新问题,为文档中的字段提供所有值(已更改的和未更改的)。MongoDB
replace命令可用于用新文档替换文档。
在问题跟踪器的情况下,id和created字段是特殊的,因为它们仅在问题被创建时被初始化,并且在那之后从不被修改。替换方法必然意味着原始对象被读取并与 API 输入提供的新值合并,否则id和created字段将获得新的值,就像在create API 中一样。相同的输入数据类型IssueInputs也可用于替换操作。
如果我们使用 update 方法,其中只提供了一些字段,我们必须维护这个可以在 GraphQL 模式中更新的字段列表。这种数据类型与IssueInputs数据类型非常相似,只是所有字段都是可选的。缺点是输入字段列表的改变需要改变IssueInputs和这个新的数据类型。
但是更新方法提供了一些灵活性。它允许用户界面非常容易地更新单个字段。在接下来的部分中,我们将添加直接从问题列表中关闭问题的功能,您可以看到这种方法如何很好地支持这两种用例:从编辑页面替换问题以及从问题列表页面更改单个字段。
在其他情况下,支持替换操作可能会更好,但是created字段的出现使得只支持更新操作更有吸引力。在这种情况下,替换可以被视为对所有可修改字段的更新。
所以让我们实现一个更新问题的 API,就像 MongoDB update()命令一样,使用$set操作符。让我们首先更改模式以反映这个新的 API:我们首先需要一个新的输入数据类型,它包含所有可以更改的可能字段,并且所有字段都是可选的。姑且称之为IssueUpdateInputs。然后,我们需要一个新的突变切入点,姑且称之为updateIssue。这将返回新修改的问题。对schema.graphql的更改如清单 10-15 所示。
...
type IssueInputs {
...
}
"""Inputs for issueUpdate: all are optional. Whichever is specified will
be set to the given value, undefined fields will remain unmodified."""
input IssueUpdateInputs {
title: String
status: StatusType
owner: String
effort: Int
due: GraphQLDate
description: String
}
...
type Mutation {
...
issueUpdate(id: Int!, changes: IssueUpdateInputs!): Issue!
}
然后,让我们将 API 连接到它在api_handler.js中的解析器。清单 10-16 显示了这一变化。
...
const resolvers = {
...
Mutation: {
...
issueUpdate: issue.update,
},
...
Listing 10-16.api/api_handler.js: New API Endpoint and Resolver for updateIssue
Listing 10-15.api/schema.graphql: Update API and Its Input Data Type
现在,我们可以在名为update()的函数中实现issue.js中的实际解析器。在这个函数中,我们需要根据新的输入来验证问题。最简单的方法是从数据库中获取完整的对象,合并提供给 API 的更改,并运行我们用于添加问题的验证。让我们也只在影响有效性的字段发生变化时运行验证:标题、状态或所有者。一旦验证成功,我们就可以使用updateOne() MongoDB 函数和$set操作来保存更改。
最后,我们需要导出update()函数以及module.exports中的其他导出函数。所有这些变化如清单 10-17 所示。
async function update(_, { id, changes }) {
const db = getDb();
if (changes.title || changes.status || changes.owner) {
const issue = await db.collection('issues').findOne({ id });
Object.assign(issue, changes);
validate(issue);
}
await db.collection('issues').updateOne({ id }, { $set: changes });
const savedIssue = await db.collection('issues').findOne({ id });
return savedIssue;
}
module.exports = { list, add, get };
list,
add,
get,
update,
};
...
Listing 10-17.api/issue.js: Resolver for the Update API
现在,你可以用操场来测试这些变化。为此,您可以使用以下命名查询:
mutation issueUpdate($id: Int!, $changes: IssueUpdateInputs!) {
issueUpdate(id: $id, changes: $changes) {
id title status owner
effort created due description
}
}
要更改 ID 为 2 的问题的状态和所有者,您可以使用如下查询变量:
{ "id": 2, "changes": { "status": "Assigned", "owner":"Eddie" } }
您还可以测试无效的更改,例如少于三个字符的标题,或者当“状态”设置为“已分配”时为空所有者,并确保更改被错误地拒绝。
更新问题
现在我们有了一个功能更新 API,我们可以编写handleSubmit()方法来调用 API 以保存用户所做的更改。
我们可以像上一节中使用的操场测试一样使用命名查询。至于名为changes的查询变量,我们需要从issue对象中剥离不能更改的字段并复制它。不能更改的字段是id和created。可以这样做:
...
const { id, created, ...changes } = issue;
...
注意
我们使用 ES2015+ rest操作符...在对id和created变量进行析构赋值后,将发布对象值的剩余部分收集到changes变量中。
使用 GraphQL API 使用命名查询保存对象后,让我们用返回的问题值替换当前显示的问题。这仅仅需要一个带有返回问题的setState()呼叫,如果有的话。让我们也显示一条警告消息来表明操作成功,因为 UI 中没有其他可见的变化。
...
const data = await graphQLFetch(query, { changes, id });
if (data) {
this.setState({ issue: data.issueUpdate });
alert('Updated issue successfully'); // eslint-disable-line no-alert
}
...
此外,如果表单中有无效字段,我们什么也不做就返回。为此,我们可以应用与显示无效字段消息相同的检查。清单 10-18 显示了从编辑问题页面更新问题的完整更改。
...
async handleSubmit(e) {
e.preventDefault();
const { issue, invalidFields } = this.state;
console.log(issue); // eslint-disable-line no-console
if (Object.keys(invalidFields).length !== 0) return;
const query = `mutation issueUpdate(
$id: Int!
$changes: IssueUpdateInputs!
) {
issueUpdate(
id: $id
changes: $changes
) {
id title status owner
effort created due description
}
}`;
const { id, created, ...changes } = issue;
const data = await graphQLFetch(query, { changes, id });
if (data) {
this.setState({ issue: data.issueUpdate });
alert('Updated issue successfully'); // eslint-disable-line no-alert
}
}
...
Listing 10-18.ui/src/IssueEdit.jsx: Changes for Saving Edits to the Database
现在,您可以测试应用,将对问题的任何更改保存到数据库中。在“编辑”和“问题列表”页面中应该可以看到这些更改。
更新字段
现在,让我们使用相同的 API 一次性更新单个字段,而不是整个问题对象。假设我们需要一种从问题列表页面本身快速关闭问题的方法(即,将其状态设置为Closed)。
为了实现这一点,我们需要在每一行都有一个按钮来启动操作。让我们更改IssueTable组件,将这个按钮添加为 Actions 列的一部分。点击这个按钮时,我们需要启动一个关闭动作,这个动作可以是一个在 props 中作为回调传递的函数。回调需要从IssueList经由IssueTable传递到IssueRow。此外,为了识别要关闭哪个问题的*,我们还必须接收表中问题的索引,作为 props 中的另一个值。索引可以在IssueTable组件本身中计算,同时遍历问题列表。*
清单 10-19 中显示了对IssueRow和IssueTable组件的更改。
...
const IssueRow = withRouter(({ issue, location: { search } }) => {
issue,
location: { search },
closeIssue,
index,
}) => {
...
<NavLink to={selectLocation}>Select</NavLink>
{' | '}
<button type="button" onClick={() => { closeIssue(index); }}>
Close
</button>
...
});
export default function IssueTable({ issues, closeIssue }) {
const issueRows = issues.map((issue, index) => (
<IssueRow key={issue.id} issue={issue} />
key={issue.id}
issue={issue}
closeIssue={closeIssue}
index={index}
/>
));
...
}
...
Listing 10-19.ui/src/IssueTable.jsx: Changes for Adding a Close Button
现在,让我们在IssueList组件中实现closeIssue()方法。让我们有一个名为closeIssue的命名查询,它接受一个问题 ID 作为查询变量。在查询的实现中,我们将调用类似于常规的update调用的issueUpdate API,但是将更改硬编码为将状态设置为 closed。
...
const query = `mutation issueClose($id: Int!) {
issueUpdate(id: $id, changes: { status: Closed }) {
...
}
}`;
...
执行查询后,如果执行成功,我们用返回值中的问题替换相同索引处的问题。因为状态是不可变的,所以我们必须复制一份issue状态变量。此外,因为我们使用现有状态的剩余部分进行复制,所以我们必须做推荐的事情,对接受前一个状态的this.setState()使用回调。如果执行不成功,我们将重新加载全部数据。
...
if (data) {
this.setState((prevState) => {
const newList = [...prevState.issues];
newList[index] = data.issueUpdate;
return { issues: newList };
});
} else {
this.loadData();
}
...
对IssueList组件的其他更改是在道具中将closeIssue()方法作为回调传递给IssueTable,并将closeIssue()方法绑定到this。清单 10-20 显示了完整的变更集,包括IssueList组件中的变更。
...
constructor() {
...
this.closeIssue = this.closeIssue.bind(this);
}
...
async createIssue(issue) {
...
}
async closeIssue(index) {
const query = `mutation issueClose($id: Int!) {
issueUpdate(id: $id, changes: { status: Closed }) {
id title status owner
effort created due description
}
}`;
const { issues } = this.state;
const data = await graphQLFetch(query, { id: issues[index].id });
if (data) {
this.setState((prevState) => {
const newList = [...prevState.issues];
newList[index] = data.issueUpdate;
return { issues: newList };
});
} else {
this.loadData();
}
}
...
render() {
...
<IssueTable issues={issues} closeIssue={this.closeIssue} />
...
}
...
Listing 10-20.ui/src/IssueList.jsx: Changes for Handling Click of Close Button
可以通过单击问题列表中任何一行的关闭按钮来测试这组更改。该行中的问题状态应更改为Closed。
练习:更新字段
- 我们可以从
IssueRow本身调用更新 API 吗?这样做的后果是什么?
本章末尾有答案。
删除 API
为了完成 CRUD 操作集,让我们实现最后一个操作,Delete。让我们首先实现一个删除 API。首先,我们将修改模式以包含 Delete API,它只接受要删除的字段的 ID。我们将返回一个布尔值来表示成功删除。这一变化如清单 10-21 所示。
...
type Mutation {
...
issueDelete(id: Int!): Boolean!
}
...
Listing 10-21.api/schema.graphql: Changes for Adding a Delete API
接下来,我们将在 API 处理程序的issue.js中将 API 连接到它的解析器。我们将简单地调用issue.js中的函数delete。这一变化如清单 10-22 所示。
...
const resolvers = {
...
Mutation: {
...
issueDelete: issue.delete,
},
...
};
...
Listing 10-22.api/api_handler.js: Changes for Adding a Delete API
现在,让我们实现删除 API 的解析器。不只是删除给定 ID 的记录,而是让我们做一个在计算机中删除文件时通常会发生的事情:它被移到垃圾箱。这是为了让我们有机会在以后的时间点恢复它。让我们使用一个名为deleted_issues的新集合来存储所有删除的问题。我们可能会决定定期清除该表,因此我们还可以添加一个deleted字段来保存删除的日期和时间,这很方便(例如,清除 30 天前删除的所有问题)。
为此,我们将基于给定的 ID 从issues集合中检索问题,添加deleted字段,将其保存到deleted_issues,然后从issues集合中删除它。注意,我们不能将函数命名为delete,因为delete是 JavaScript 中的保留关键字。因此,我们将函数命名为remove(),但是我们将使用名称delete导出它。清单 10-23 显示了实现解析器的更改。
...
async function update(_, { id, changes }) {
...
}
async function remove(_, { id }) {
const db = getDb();
const issue = await db.collection('issues').findOne({ id });
if (!issue) return false;
issue.deleted = new Date();
let result = await db.collection('deleted_issues').insertOne(issue);
if (result.insertedId) {
result = await db.collection('issues').removeOne({ id });
return result.deletedCount === 1;
}
return false;
}
module.exports = {
list,
add,
get,
update,
delete: remove,
};
...
Listing 10-23.api/issue.js: Addition of Resolver for the Delete API
最后,让我们也作为初始化脚本的一部分初始化这个集合。这包括两件事:首先清理集合,然后在 ID 字段上创建一个索引以便于检索。对此的更改如清单 10-24 所示。
...
db.issues.remove({});
db.deleted_issues.remove({});
...
db.issues.createIndex({ created: 1 });
db.deleted_issues.createIndex({ id: 1 }, { unique: true });
...
Listing 10-24.api/scripts/init.mongo.js: Initialization of deleted_issues Collection
现在,您可以使用 Playground 测试 Delete API。您可以使用如下的变异来删除 ID 为 4 的问题:
mutation {
issueDelete(id: 4)
}
如果 ID 为 4 的问题存在,它将被删除,API 将返回true。否则,API 将返回false。您可以使用 MongoDB shell 检查集合deleted_issues的内容,以验证该问题已经在该集合中备份。
删除问题
删除问题的 UI 更改与我们使用 Close 按钮更新字段的更改非常相似。
让我们首先添加按钮,并通过IssueTable向IssueRows传递必要的回调。让我们使用名称deleteIssue进行回调,我们将很快在IssueList中实现。就像closeIssue回调一样,我们需要删除该问题的索引。我们已经为此目的传入了索引,所以我们将在这里使用同样的方法。
对IssueTable和IssueRows的更改如清单 10-25 所示。
...
const IssueRow = withRouter(({
...
deleteIssue,
index,
}) => {
...
<button type="button" onClick={() => { closeIssue(index); }}>
Close
</button>
{' | '}
<button type="button" onClick={() => { deleteIssue(index); }}>
Delete
</button>
...
});
export default function IssueTable({ issues, closeIssue, deleteIssue }) {
const issueRows = issues.map((issue, index) => (
<IssueRow
...
deleteIssue={deleteIssue}
index={index}
/>
...
Listing 10-25.ui/src/IssueTable.jsx: Changes for Delete Button and Handling It
下一组变化是在IssueList组件中。同样,更改与我们对关闭按钮所做的非常相似:一个deleteIssue()方法获取要删除的问题的索引,在查询变量中使用这个 id 调用删除 API,如果 API 成功,从issues状态变量中删除该问题。如果没有,它将重新加载数据。此外,存在用户正在删除所选问题的可能性。在这种情况下,让我们恢复到一个未选中的视图,即导航回/issues(即没有 ID 后缀)。
其他变化是将新方法绑定到this并将该方法作为回调传递给IssueTable组件。
这些变化如清单 10-26 所示。
...
constructor() {
...
this.deleteIssue = this.deleteIssue.bind(this);
}
...
async closeIssue(index) {
...
}
async deleteIssue(index) {
const query = `mutation issueDelete($id: Int!) {
issueDelete(id: $id)
}`;
const { issues } = this.state;
const { location: { pathname, search }, history } = this.props;
const { id } = issues[index];
const data = await graphQLFetch(query, { id });
if (data && data.issueDelete) {
this.setState((prevState) => {
const newList = [...prevState.issues];
if (pathname === `/issues/${id}`) {
history.push({ pathname: '/issues', search });
}
newList.splice(index, 1);
return { issues: newList };
});
} else {
this.loadData();
}
}
render() {
...
<IssueTable issues={issues} closeIssue={this.closeIssue} />
issues={issues}
closeIssue={this.closeIssue}
deleteIssue={this.deleteIssue}
/>
}
...
Listing 10-26.ui/src/IssueList.jsx: Changes for Implementing Delete Functionality
现在,如果您测试这个应用,您会在 Action 列中发现一个附加的 Delete 按钮。如果您单击“删除”,您应该会发现该问题已从列表中删除。在这一点上,我们不要求确认删除,因为最终我们将添加一个撤销按钮,恢复已删除的问题。这样,如果用户误点击了删除,他们可以撤销他们的操作。带有删除按钮的问题列表页面将类似于图 10-4 所示的屏幕截图。
图 10-4。
带有删除按钮的问题列表
摘要
我们使用编辑页面浏览表单,并查看受控和非受控表单组件之间的区别。我们还添加了新的 API 来满足新表单的需求,并通过添加删除操作完成了 CRUD 范例。重要的是,我们创建了专门的输入组件,可以处理大多数应用中预期的不同数据类型。
当我们做这一切的时候,你一定有一个想法:我们能不能让这一切,尤其是编辑页面,在浏览器中看起来更好?这正是我们将在下一章着手做的事情。我们将使用一个流行的 CSS 库来为 UI 添加一些修饰。
练习答案
练习:更多过滤器
-
MongoDB 在数据类型方面非常严格。这也意味着,对于没有值的字段,它不能确定类型,因此,如果该字段有一个筛选条件,匹配就不会发生。如果字段上有 any 过滤器,则任何具有空值的字段都将被忽略且不会返回。
-
如果我们确实需要返回缺少工作字段的文档,我们必须创建一个包含原始过滤器的条件,以及一个工作未定义的条件。
$or操作符接受一组过滤器,并根据任何过滤条件匹配文档。为了匹配未定义工作字段的文档,我们必须使用
{$exists: false}作为字段的标准。下面是 mongo shell 中的一个例子:> db.issues.find({$or: [ {effort: {$lte: 10}}, {effort: {$exists: false}} ]});
练习:打字输入
-
即使发送的查询带有针对
effortMin和/或effortMax的字符串,服务器似乎也会接受它并自动将其转换为整数。在将 effort 字段发送到服务器之前,该查询会将它们转换为整数。尽管这看起来很方便,而且不在 UI 中添加转换也很诱人,但出于几个原因,不建议这样做。首先,graphql-js 库的这种行为将来可能会改变,如果发生这种情况,可能会破坏我们的实现。以整数形式提供整数值更安全。
其次,如果解析的值不是数字,UI 会忽略任何非数字值。因此,应用就像没有提供过滤器一样工作。另一方面,如果该值没有被解析,文本输入将被发送到服务器,从而导致错误(这可以通过直接在浏览器的 URL 中键入这些非数字值来测试)。
-
如果您将输入的类型设置为数字,您会发现(a)它在不同的浏览器上表现不同,(b)屏蔽在某些浏览器上不起作用,以及(c)当它允许无效字符时,您在
onChange中看不到它们。这是因为根据 HTML 规范,当指定了类型并且输入不符合规范时,输入的value应该返回一个空字符串。如何处理无效值也取决于浏览器;例如,一些浏览器可能会显示输入无效的事实,而其他浏览器可能会阻止无效的输入。当使用 React 时,最好不要使用输入字段的 type 属性,最好自己处理验证或屏蔽(或者使用为您做这件事的包)。这使得跨浏览器的行为是可预测的,并允许您对如何处理无效输入做出明智的决定,特别是为了获得有效值而暂时无效的输入。
练习:编辑页面
- 如果受控组件的值设置为 null,则 React 会在控制台上显示一条警告:
警告:
'textarea'上的'value'属性不应为空。考虑使用空字符串来清除组件,或者使用'undefined'来清除不受控制的组件。
警告是因为空值是一个信号,表示组件不受控制。受控组件必须具有非空值。
练习:更新字段
-
虽然启动 API 可以从
IssueRow组件本身完成,但发出成功信号和更新问题列表只能在IssueList组件内完成,因为状态驻留在那里。此外,这将导致IssueRow组件不再是一个纯粹的函数。它还需要一个组件内 close 动作的处理程序,这使得有必要将其定义为一个类。因为状态驻留在
IssueList组件中,所以最好也让同一个组件操纵状态。