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

edit user mockup bootstrap图 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 变量的属性值填写相应的字段。

edit page 3rd edition图 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 所示。

edit with invalid information 3rd edition图 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 请求,用法与 getpostdelete 类似。

代码清单 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 所示。下面我们来实现编辑其他信息的功能。

gravatar cropper图 9.4:Gravatar 的图片剪切界面,上传了一个帅哥的图片

上手测试后,你可能会发现,编写应用代码之前编写测试比之后再写更有用。针对现在这种情况,我们要编写的是“验收测试”(acceptance test),由测试的结果决定某个功能是否完成。为了演示如何编写验收测试,我们要使用测试驱动开发技术完成用户编辑功能。

我们要编写类似代码清单 9.6 中的测试,确认更新用户的操作表现正确,只不过这一次我们会提交有效的信息。然后检查显示了闪现消息,而且成功重定向到了用户的资料页面,同时还要确认数据库中保存的用户信息也正确更新了。这个测试如代码清单 9.8 所示。注意,在代码清单 9.8 中,密码和密码确认都为空值,因为修改用户名和电子邮件地址时并不想修改密码。还要注意,我们使用 @user.reload6.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

edit form working图 9.5:编辑成功后显示的页面