💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
# 9.1 更新用户 编辑用户信息的方法和创建新用户差不多(参见[第 7 章](chapter7.html#sign-up)),创建新用户的页面在 `new` 动作中处理,而编辑用户的页面在 `edit` 动作中处理;创建用户的过程在 `create` 动作中处理 `POST` 请求,编辑用户要在 `update` 动作中处理 `PATCH` 请求([旁注 3.2](chapter3.html#aside-get-etc))。二者之间最大的区别是,任何人都可以注册,但只有当前用户才能更新自己的信息。我们可以使用[第 8 章](chapter8.html#log-in-log-out)实现的认证机制,通过“事前过滤器”(before filter)实现访问限制。 开始实现之前,我们先切换到 `updating-users` 主题分支: ``` $ git checkout master $ git checkout -b updating-users ``` ## 9.1.1 编辑表单 我们先来创建编辑表单,构思图如[图 9.1](#fig-edit-user-mockup)。[[1](#fn-1)]要把这个构思图转换成可以使用的页面,我们既要编写用户控制器的 `edit` 动作,也要创建编辑用户的视图。我们先来编写 `edit` 动作。在 `edit` 动作中我们要从数据库中读取相应的用户。由[表 7.1](chapter7.html#table-restful-users) 得知,用户的编辑页面地址是 /users/1/edit(假设用户的 ID 是 1)。我们知道用户的 ID 可以使用 `params[:id]` 获取,那么就可以使用[代码清单 9.1](#listing-initial-edit-action) 中的代码查找用户。 ##### 代码清单 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](https://box.kancloud.cn/2016-05-11_5733305accabe.png)图 9.1:用户编辑页面的构思图 用户编辑页面的视图(要手动创建这个文件)如[代码清单 9.2](#listing-user-edit-view) 所示。注意,这个视图和[代码清单 7.13](chapter7.html#listing-signup-form) 中新建用户的视图很相似,有很多重复的代码,所以可以重构,把共用的代码放到局部视图中,这个任务留作练习([9.6 节](#updating-showing-and-deleting-users-exercises))。 ##### 代码清单 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 节](chapter7.html#signup-error-messages)创建的 `error_messages` 局部视图。顺便说一下,修改 Gravatar 头像的链接用到了 `target="_blank"`,目的是在新窗口或选项卡中打开这个网页。链接到第三方网站时一般都会这么做。 [代码清单 9.1](#listing-initial-edit-action) 中定义了 `@user` 实例变量,所以编辑页面可以正确渲染,如[图 9.2](#fig-edit-page) 所示。从“Name”和“Email”字段可以看出,Rails 会自动使用 `@user` 变量的属性值填写相应的字段。 ![edit page 3rd edition](https://box.kancloud.cn/2016-05-11_5733305b01c14.png)图 9.2:编辑页面初始版本,名字和电子邮件地址自动填入了值 查看用户编辑页面的 HTML 源码,会看到预期的表单标签,如[代码清单 9.3](#listing-edit-form-html) 所示(某些细节可能不同)。 ##### 代码清单 9.3:[代码清单 9.2](#listing-user-edit-view) 定义的编辑表单生成的 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](chapter7.html#table-restful-users) 中的 REST 动作要用),所以 Rails 在 `POST` 请求中使用这个隐藏字段伪造了一个 `PATCH` 请求。[[2](#fn-2)] 还有一个细节需要注意一下,[代码清单 9.2](#listing-user-edit-view) 和[代码清单 7.13](chapter7.html#listing-signup-form) 都使用了相同的 `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](chapter7.html#table-restful-users) 中列出的 `edit_user_path` 具名路由,并把参数设为[代码清单 8.36](chapter8.html#listing-persistent-current-user) 中定义的 `current_user` 辅助方法: ``` <%= link_to "Settings", edit_user_path(current_user) %> ``` 完整的视图如[代码清单 9.4](#listing-settings-link) 所示。 ##### 代码清单 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 节](chapter7.html#unsuccessful-signups))。我们要先定义 `update` 动作,把提交的 `params` 哈希传给 `update_attributes` 方法([6.1.5 节](chapter6.html#updating-user-objects)),更新用户,如[代码清单 9.5](#listing-user-update-action-unsuccessful) 所示。如果提交的数据无效,更新操作会返回 `false`,由 `else` 分支处理,重新渲染编辑页面。我们之前用过类似的处理方式,代码结构和第一个版本的 `create` 动作类似([代码清单 7.16](chapter7.html#listing-first-create-action))。 ##### 代码清单 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 节](chapter7.html#strong-parameters))。 因为用户模型中定义了验证规则,而且[代码清单 9.2](#listing-user-edit-view) 中渲染了错误消息局部视图,所以提交无效信息后会显示一些有用的错误消息,如[图 9.3](#fig-buggy-edit-with-invalid-information) 所示。 ![edit with invalid information 3rd edition](https://box.kancloud.cn/2016-05-11_5733305b1ea67.png)图 9.3:提交编辑表单后显示的错误消息 ## 9.1.3 编辑失败的测试 [9.1.2 节](#unsuccessful-edits)结束时编辑表单已经可以使用,按照[旁注 3.3](chapter3.html#aside-when-to-test) 中的测试指导方针,现在我们要编写集成测试捕获回归。和之前一样,首先要生成一个集成测试文件: ``` $ rails generate integration_test users_edit invoke test_unit create test/integration/users_edit_test.rb ``` 然后为编辑失败编写一个简单的测试,如[代码清单 9.6](#listing-unsuccessful-edit-test) 所示。在这段测试中,我们检查提交无效信息后会重新渲染编辑模板,以此确认表现是否正确。注意,这里使用 `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](#fig-edit-page) 中的“change”链接就可以了,如[图 9.4](#fig-gravatar-cropper) 所示。下面我们来实现编辑其他信息的功能。 ![gravatar cropper](https://box.kancloud.cn/2016-05-11_5733305b3ae6b.png)图 9.4:Gravatar 的图片剪切界面,上传了一个[帅哥](http://www.michaelhartl.com/)的图片 上手测试后,你可能会发现,编写应用代码之前编写测试比之后再写更有用。针对现在这种情况,我们要编写的是“验收测试”(acceptance test),由测试的结果决定某个功能是否完成。为了演示如何编写验收测试,我们要使用测试驱动开发技术完成用户编辑功能。 我们要编写类似[代码清单 9.6](#listing-unsuccessful-edit-test) 中的测试,确认更新用户的操作表现正确,只不过这一次我们会提交有效的信息。然后检查显示了闪现消息,而且成功重定向到了用户的资料页面,同时还要确认数据库中保存的用户信息也正确更新了。这个测试如[代码清单 9.8](#listing-successful-edit-test) 所示。注意,在[代码清单 9.8](#listing-successful-edit-test) 中,密码和密码确认都为空值,因为修改用户名和电子邮件地址时并不想修改密码。还要注意,我们使用 `@user.reload`([6.1.5 节](chapter6.html#updating-user-objects)首次用到)重新加载数据库中存储的值,以此确认成功更新了信息。(新手很容易忘记这个操作,这就是为什么必须要有一定的经验才能编写有效的验收测试(推及到 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 = "foo@bar.com" 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](#listing-successful-edit-test) 中的测试通过,我们可以参照最终版 `create` 动作([代码清单 8.22](chapter8.html#listing-login-upon-signup))来编写 `update` 动作,如[代码清单 9.9](#listing-user-update-action) 所示。 ##### 代码清单 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](#listing-user-update-action) 的标题所示,测试组件无法通过,因为密码长度验证([代码清单 6.39](chapter6.html#listing-password-implementation))失败了,这是因为[代码清单 9.8](#listing-successful-edit-test) 中密码和密码确认都是空值。为了让测试通过,我们要在密码为空值时特殊处理最短长度验证,方法是把 `allow_nil: true` 参数传给 `validates` 方法,如[代码清单 9.10](#listing-allow-blank-password) 所示。 ##### 代码清单 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 节](chapter6.html#minimum-password-standards)说过,创建对象时,`has_secure_password` 会执行存在性验证,捕获密码为 `nil` 的情况。(密码为 `nil` 时能通过存在性验证,可是会被 `has_secure_password` 方法的验证捕获,因此修正了 [7.3.3 节](chapter7.html#signup-error-messages)提到的错误消息重复问题。) 至此,用户编辑页面应该可以正常使用了,如[图 9.5](#fig-edit-form-working) 所示。你也可以运行测试组件确认一下,应该可以通过: ##### 代码清单 9.11:**GREEN** ``` $ bundle exec rake test ``` ![edit form working](https://box.kancloud.cn/2016-05-11_5733305b5d69f.png)图 9.5:编辑成功后显示的页面