9.1 更新用户
编辑用户信息的方法和创建新用户差不多(参见第 7 章),创建新用户的页面在 new
动作中处理,而编辑用户的页面在 edit
动作中处理;创建用户的过程在 create
动作中处理 POST
请求,编辑用户要在 update
动作中处理 PATCH
请求(旁注 3.2)。二者之间最大的区别是,任何人都可以注册,但只有当前用户才能更新自己的信息。我们可以使用第 8 章实现的认证机制,通过“事前过滤器”(before filter)实现访问限制。
开始实现之前,我们先切换到 updating-users
主题分支:
$ git checkout master
$ git checkout -b updating-users
9.1.1 编辑表单
我们先来创建编辑表单,构思图如图 9.1。[1]要把这个构思图转换成可以使用的页面,我们既要编写用户控制器的 edit
动作,也要创建编辑用户的视图。我们先来编写 edit
动作。在 edit
动作中我们要从数据库中读取相应的用户。由表 7.1 得知,用户的编辑页面地址是 /users/1/edit(假设用户的 ID 是 1)。我们知道用户的 ID 可以使用 params[:id]
获取,那么就可以使用代码清单 9.1 中的代码查找用户。
代码清单 9.1:用户控制器的 edit
动作
app/controllers/users_controller.rb
class UsersController < ApplicationController
def show
@user = User.find(params[:id])
end
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save
log_in @user
flash[:success] = "Welcome to the Sample App!"
redirect_to @user
else
render 'new'
end
end
def edit
@user = User.find(params[:id]) end
private
def user_params
params.require(:user).permit(:name, :email, :password,
:password_confirmation)
end
end
图 9.1:用户编辑页面的构思图
用户编辑页面的视图(要手动创建这个文件)如代码清单 9.2 所示。注意,这个视图和代码清单 7.13 中新建用户的视图很相似,有很多重复的代码,所以可以重构,把共用的代码放到局部视图中,这个任务留作练习(9.6 节)。
代码清单 9.2:用户编辑页面的视图
app/views/users/edit.html.erb
<% provide(:title, "Edit user") %>
<h1>Update your profile</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for(@user) do |f| %>
<%= render 'shared/error_messages' %>
<%= f.label :name %>
<%= f.text_field :name, class: 'form-control' %>
<%= f.label :email %>
<%= f.email_field :email, class: 'form-control' %>
<%= f.label :password %>
<%= f.password_field :password, class: 'form-control' %>
<%= f.label :password_confirmation, "Confirmation" %>
<%= f.password_field :password_confirmation, class: 'form-control' %>
<%= f.submit "Save changes", class: "btn btn-primary" %>
<% end %>
<div class="gravatar_edit">
<%= gravatar_for @user %>
<a href="http://gravatar.com/emails" target="_blank">change</a>
</div>
</div>
</div>
这里再次用到了 7.3.3 节创建的 error_messages
局部视图。顺便说一下,修改 Gravatar 头像的链接用到了 target="_blank"
,目的是在新窗口或选项卡中打开这个网页。链接到第三方网站时一般都会这么做。
代码清单 9.1 中定义了 @user
实例变量,所以编辑页面可以正确渲染,如图 9.2 所示。从“Name”和“Email”字段可以看出,Rails 会自动使用 @user
变量的属性值填写相应的字段。
图 9.2:编辑页面初始版本,名字和电子邮件地址自动填入了值
查看用户编辑页面的 HTML 源码,会看到预期的表单标签,如代码清单 9.3 所示(某些细节可能不同)。
代码清单 9.3:代码清单 9.2 定义的编辑表单生成的 HTML
<form accept-charset="UTF-8" action="/users/1" class="edit_user"
id="edit_user_1" method="post">
<input name="_method" type="hidden" value="patch" />
.
.
.
</form>
留意一下这个隐藏字段:
<input name="_method" type="hidden" value="patch" />
因为浏览器并不支持发送 PATCH
请求(表 7.1 中的 REST 动作要用),所以 Rails 在 POST
请求中使用这个隐藏字段伪造了一个 PATCH
请求。[2]
还有一个细节需要注意一下,代码清单 9.2 和代码清单 7.13 都使用了相同的 form_for(@user)
来构建表单,那么 Rails 是怎么知道创建新用户要发送 POST
请求,而编辑用户时要发送 PATCH
请求的呢?这个问题的答案是,通过 Active Record 提供的 new_record?
方法检测用户是新创建的还是已经存在于数据库中:
$ rails console
>> User.new.new_record?
=> true
>> User.first.new_record?
=> false
所以使用 form_for(@user)
构建表单时,如果 @user.new_record?
返回 true
,发送 POST
请求,否则发送 PATCH
请求。
最后,我们要把导航中指向编辑用户页面的链接换成真实的地址。很简单,我们直接使用表 7.1 中列出的 edit_user_path
具名路由,并把参数设为代码清单 8.36 中定义的 current_user
辅助方法:
<%= link_to "Settings", edit_user_path(current_user) %>
完整的视图如代码清单 9.4 所示。
代码清单 9.4:在网站布局中设置“Settings”链接的地址
app/views/layouts/_header.html.erb
<header class="navbar navbar-fixed-top navbar-inverse">
<div class="container">
<%= link_to "sample app", root_path, id: "logo" %>
<nav>
<ul class="nav navbar-nav navbar-right">
<li><%= link_to "Home", root_path %></li>
<li><%= link_to "Help", help_path %></li>
<% if logged_in? %>
<li><%= link_to "Users", '#' %></li>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
Account <b class="caret"></b>
</a>
<ul class="dropdown-menu">
<li><%= link_to "Profile", current_user %></li>
<li><%= link_to "Settings", edit_user_path(current_user) %></li>
<li class="divider"></li>
<li>
<%= link_to "Log out", logout_path, method: "delete" %>
</li>
</ul>
</li>
<% else %>
<li><%= link_to "Log in", login_path %></li>
<% end %>
</ul>
</nav>
</div>
</header>
9.1.2 编辑失败
本节我们要处理编辑失败的情况,过程和处理注册失败差不多(7.3 节)。我们要先定义 update
动作,把提交的 params
哈希传给 update_attributes
方法(6.1.5 节),更新用户,如代码清单 9.5 所示。如果提交的数据无效,更新操作会返回 false
,由 else
分支处理,重新渲染编辑页面。我们之前用过类似的处理方式,代码结构和第一个版本的 create
动作类似(代码清单 7.16)。
代码清单 9.5:update
动作初始版本
app/controllers/users_controller.rb
class UsersController < ApplicationController
def show
@user = User.find(params[:id])
end
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save log_in @user
flash[:success] = "Welcome to the Sample App!"
redirect_to @user
else
render 'new' end
end
def edit
@user = User.find(params[:id])
end
def update
@user = User.find(params[:id])
if @user.update_attributes(user_params) # 处理更新成功的情况
else
render 'edit' end
end
private
def user_params
params.require(:user).permit(:name, :email, :password,
:password_confirmation)
end
end
注意在调用 update_attributes
方法时指定的 user_params
参数,这种用法是“健壮参数”(strong parameter),可以避免批量赋值带来的安全隐患(参见 7.3.2 节)。
因为用户模型中定义了验证规则,而且代码清单 9.2 中渲染了错误消息局部视图,所以提交无效信息后会显示一些有用的错误消息,如图 9.3 所示。
图 9.3:提交编辑表单后显示的错误消息
9.1.3 编辑失败的测试
9.1.2 节结束时编辑表单已经可以使用,按照旁注 3.3 中的测试指导方针,现在我们要编写集成测试捕获回归。和之前一样,首先要生成一个集成测试文件:
$ rails generate integration_test users_edit
invoke test_unit
create test/integration/users_edit_test.rb
然后为编辑失败编写一个简单的测试,如代码清单 9.6 所示。在这段测试中,我们检查提交无效信息后会重新渲染编辑模板,以此确认表现是否正确。注意,这里使用 patch
方法发起 PATCH
请求,用法与 get
、post
和 delete
类似。
代码清单 9.6:编辑失败的测试 GREEN
test/integration/users_edit_test.rb
require 'test_helper'
class UsersEditTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
test "unsuccessful edit" do
get edit_user_path(@user)
patch user_path(@user), user: { name: '',
email: 'foo@invalid',
password: 'foo',
password_confirmation: 'bar' }
assert_template 'users/edit'
end
end
此时,测试组件应该可以通过:
代码清单 9.7:GREEN
$ bundle exec rake test
9.1.4 编辑成功(使用 TDD)
现在我们要让编辑表单能正常使用。编辑头像的功能已经有了,因为我们把上传头像的操作交由 Gravatar 处理,如需更换头像,点击图 9.2 中的“change”链接就可以了,如图 9.4 所示。下面我们来实现编辑其他信息的功能。
图 9.4:Gravatar 的图片剪切界面,上传了一个帅哥的图片
上手测试后,你可能会发现,编写应用代码之前编写测试比之后再写更有用。针对现在这种情况,我们要编写的是“验收测试”(acceptance test),由测试的结果决定某个功能是否完成。为了演示如何编写验收测试,我们要使用测试驱动开发技术完成用户编辑功能。
我们要编写类似代码清单 9.6 中的测试,确认更新用户的操作表现正确,只不过这一次我们会提交有效的信息。然后检查显示了闪现消息,而且成功重定向到了用户的资料页面,同时还要确认数据库中保存的用户信息也正确更新了。这个测试如代码清单 9.8 所示。注意,在代码清单 9.8 中,密码和密码确认都为空值,因为修改用户名和电子邮件地址时并不想修改密码。还要注意,我们使用 @user.reload
(6.1.5 节首次用到)重新加载数据库中存储的值,以此确认成功更新了信息。(新手很容易忘记这个操作,这就是为什么必须要有一定的经验才能编写有效的验收测试(推及到 TDD)的原因。)
代码清单 9.8:编辑成功的测试 RED
test/integration/users_edit_test.rb
require 'test_helper'
class UsersEditTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
.
.
.
test "successful edit" do
get edit_user_path(@user)
name = "Foo Bar"
email = "[email protected]"
patch user_path(@user), user: { name: name,
email: email,
password: "",
password_confirmation: "" }
assert_not flash.empty?
assert_redirected_to @user
@user.reload
assert_equal @user.name, name
assert_equal @user.email, email
end
end
要让代码清单 9.8 中的测试通过,我们可以参照最终版 create
动作(代码清单 8.22)来编写 update
动作,如代码清单 9.9 所示。
代码清单 9.9:用户控制器的 update
动作 RED
app/controllers/users_controller.rb
class UsersController < ApplicationController
.
.
.
def update
@user = User.find(params[:id])
if @user.update_attributes(user_params)
flash[:success] = "Profile updated" redirect_to @user else
render 'edit'
end
end
.
.
.
end
如代码清单 9.9 的标题所示,测试组件无法通过,因为密码长度验证(代码清单 6.39)失败了,这是因为代码清单 9.8 中密码和密码确认都是空值。为了让测试通过,我们要在密码为空值时特殊处理最短长度验证,方法是把 allow_nil: true
参数传给 validates
方法,如代码清单 9.10 所示。
代码清单 9.10:更新时允许密码为空 GREEN
app/models/user.rb
class User < ActiveRecord::Base
attr_accessor :remember_token
before_save { self.email = email.downcase }
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 }
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
has_secure_password
validates :password, presence: true, length: { minimum: 6 }, allow_nil: true .
.
.
end
你可能担心这么改用户注册时可以把密码设为空值,其实不然,6.3.3 节说过,创建对象时,has_secure_password
会执行存在性验证,捕获密码为 nil
的情况。(密码为 nil
时能通过存在性验证,可是会被 has_secure_password
方法的验证捕获,因此修正了 7.3.3 节提到的错误消息重复问题。)
至此,用户编辑页面应该可以正常使用了,如图 9.5 所示。你也可以运行测试组件确认一下,应该可以通过:
代码清单 9.11:GREEN
$ bundle exec rake test
图 9.5:编辑成功后显示的页面