Rails-Treasure chest3 嵌套表单; Ransack(3900✨)用于模糊查询, ranked-model(800🌟)自订列表顺序; PaperTrail(5000✨)跟踪model's data,auditing and versioning.
-
自订列表顺序, gem 'ranked-model'
-
多步骤表单
-
显示资料验证错误讯息
-
资料筛选和搜寻, gem 'ransack' (3900✨);
-
软删除和版本控制
-
数据汇出(csv),
自订列表顺序:ranked-model( 800✨) https://github.com/mixonic/ranked-model
gem 'ranked-model'
简单使用:
为Event增加一个column, :row_order,type是integer,加上index。
- 在model层加上include RankedModel换行ranks: row_order
- 在controller层使用: Event.rank(:row_order).all
- 更新一条记录的顺序 @event.update(:row_order_position, 0)
- 第二个参数,可以是数字,或者:first, :last, :up, :down方法。
如果使用一个普通的json controller, @event.attributes = params[:event]; @duck.save。
$.ajax({
type: 'PUT',
url: '/ducks',
dataType: 'json',
data: { duck: { row_order_position: 0 } },
});
在routes.rb中定义一个路径:member {post :reorder}
link_to "上移", reorder_admin_event_path(event, :position => :up), method: :post
link_to "下移", reorder_admin_event_path(event, :position => :down), method: :post
在路径中加上请求参数"position": "up"
复杂使用:
ranks接受几种参数:
class Duck < ActiveRecord::Base
include RankedModel
ranks :row_order, # 使用rank(),来定义一个ranker
:column => :sort_order # 加载这个默认列, which defaults to the name
belongs_to :pond
ranks :swimming_order,
:with_same => :pond_id # Ducks belong_to Ponds, 让ranker围绕一个pond
scope :walking, where(:walking => true )
ranks :walking_order,
:scope => :walking # Narrow this ranker to a scope
end
当你查询时,使用rank():
Duck.rank(:row_order)
Pond.first.ducks.rank(:swimming_order)
Duck.walking.rank(:walking)
Ajax UI
案例(博客地址):https://www.cnblogs.com/chentianwei/p/9443664.html
多步骤表单
(Multi Step Form,又叫做 Wizards)
什么时候会用到呢? 当表单很复杂的时候,我们不希望一次就把所有字段显示出来,这样会吓跑用户。而是会拆成步骤一、步骤二、步骤三.... 一步一步让用户掉入这个坑完成表单,以增加表单完成的成功率。
要制作的 UI 将拆分成三个表单:
- 第一个表单: 选票种
- 第二个表单: 填姓名、E-mail、电话
- 第三个表单: 填个人网站URL、填自我介绍
其中第二个表单和第三个表单,除了有下一步之外,也可以回到上一步进行修改。如果用户中途离开,下次再进来也可以继续编辑。
另一种纯前端的做法,例如 jQuery Steps,则是只用特效的方式拆成不同步骤,而没有将过程储存进到数据库,如果中离就毫无纪录。本章的做法是中间过程都会存进数据库。
实做简介:
第一步:拆路径;原本的controller是new和create, 现在拆成三部分。
resources :registrations do
# 增加1-3步的url指向contoller#action,并对url使用别名:
member do
get 'steps/1' => "registrations#step1", as: :step1
patch "steps/1/update" => "registrations#step1_update", as: :update_step1
get "steps/2" => "registrations#step2", as: :step2
patch "steps/2/update" => "registrations#step2_update", as: :update_step2
get "steps/3" => "registrations#step3", as: :step3
patch "steps/3/update" => "registrations#step3_update", as: :update_step3
end
end
注意: step1是step2返回到step1的路径,因为第一步是new和create,已经创建了记录就无需再创建了。
第二步:写各个步骤的controller和view。
controller:
def step2
@registration = @event.registrations.find_by_uuid(params[:id])
end
def step2_update
@registration = @event.registrations.find_by_uuid(params[:id])
if @registration.update(registration_params)
redirect_to step3_event_registration_path(@event, @registration)
else
render "step2"
end
end
view:
拆原来的form,注意几点:
- step2的form_for需要加上url参数, url指向的是#step2_update, HTML动作是patch
- 实例变量是form_for的参数
<%= form_for @registration, :url => update_step2_event_registration_path(@event, @registration) do |f| %>
第三步: 写各个步骤的返回上一步功能:
- view的表格底部加上连接link_to,url的HTML动作是get.
- 第2步返回第一步,使用的是自定义的路径step1_event_registration_path(), 这是因为返回到第一步是修改而不是新建,已经创建了记录就无需再创建了,所以需要从新自定义一个返回step1的路径。
第四步: validates,因为拆成几步,所以每步的验证也就不一样了。对每一步编号,然后根据编号来进行验证。
Model层:
- 加上实例变量attr_assessor :current_step
- 对validations_presence_of :name,...,后面加上:if进行判断,if返回true或false,需要定义一个方法。
validates_presence_of :name, :email, :cellphone, if: :should_validate_basic_data?
def should_validate_basic_data?
current_step == 2
end
controller层:
create, step1_update, step2_update, step3_update, 加上@registration.current_step = 1|2|3
显示资料验证错误讯息
服务器验证, 和前端验证两种:
服务器验证:
<div class="form-group <%= (f.object.errors[:name].any?)? "has-error" : "" %>">
<%= f.label :name %>
<%= f.text_field :name, :class => "form-control" %>
<% if f.object.errors[:name] %>
<span class="help-block"><%= safe_join(f.object.errors[:name], ',')%></span>
<% end %>
</div>
has-error和help-block是bootstrap3的类方法。
可以手动实现这个效果, 先对text_filed加上border-color,然后,给<span>加上style: "color: red;"
f.object
指的是这个 form_for
表单的 model 物件,也就是 @registration
f.object.errors[字段名称]
是个数组储存了这个字段的错误讯息
safe_join(arrary, sep=$,)
和Array#join(Sep=$)功能类似,先flatten,然后遍历map每个item,再使用分隔符号join每一个item,然后使用html_tag,让内含的html tag脱逸(escape check),并返回最后的结果:一个大string。
自订义资料验证的错误显示
Model层使用:
valdate :check_something, on: :create
def check_something
if 条件
errors.add(:base, "messages")
end
end
如果错误不是发生在attribute上,则使用:base。
controller上:
可以使用flash.now[:alert] = @registration.errors[:base].join(",")
now代表只在当前页面显示flash
view上:
<% if notice %>
<p class="alert alert-success"><%= notice %></p>
<% end %>
notice是flash的方法,是flash[:notice]的简写。
前端资料验证:
input中添加required特性。即可。
上述的 HTML5 验证是浏览器内建的,如果想要更漂亮的特效,我们可以考虑安装其他前端的套件。
参考 10 jQuery Form Validation Plugins 我们挑一套 Bootstrap Validator 来试试看。
这个前端套件没有包好的 Gem 可以安装,请手动下载 validator.min.js 这个 javascript 档案,放在 vendor/assets/javascripts/
目录下。
然后修改 app/assets/javascripts/application.js
加载它
+ //= require validator.min
//= require_tree .
view中:
字段上添加: <div class="help-block with-errors"></div>
javascript代码:
+ <script>
+ $("form").validator();
+ </script>
前端验证是不可靠的,用户只要关闭浏览器的 JavaScript 就可以跳过前端验证。
以防万一,还是需要后端验证的,如果前端验证失效时,至少还可以看到错误讯息。
资料筛选和搜寻
- 资料筛选,单选/多选
- 时间区间筛选
- 资料对比筛选
- 关键字search
- 前台活动状态筛选
资料筛选,单选
需求:点选按钮,根据状态或票种(单选)来从数据库中查询报名人资料。
view:
增加一排按钮,2组按钮,1组是根据status来查询,2组是根据ticket_id来查询。
重点是按钮传递的参数:
- 根据status来传递参数admin_event_registrations_path(status: s)
- 根据ticket_id来传递参数admin_event_registrations_path(ticket_id: t.id)
controller:
根据request参数的不同,来从数据库查询不同的资料。
if params[:status] && Registration::STATUS.include?(params[:status])
return @registrations = @event.registrations.includes(:ticket).where(status: params[:status])
end
if params[:ticket_id]
return @registrations = @event.registrations.includes(:ticket).where(ticket_id: params[:ticket_id])
end
然后进行重构:
第一,把query查询语法放到Model中。scope :by_status, ->(s){ where( status: s )}
第二,在页面的按钮组上,每个按钮上显示查询记录的数量。
- link_to "全部#{@event.registrations.size}", admin_event_registrations_path(@event)
- link_to t(s, scope:"registration.status") + "#{@event.registrations.by_status(s).size}", admin_event_registrations_path(status: s)
第三,凡事点击的按钮,要凹陷下去,通过增加css属性给<a>tag。或者使用bootstrap的active类。
- 在class中添加条件判断:#{(params[:status].blank?)? "active" : "" }
- 或者 #{(params[:status] == s)? "active" : ""}
这个功能目前不管用:也非单选需求下的功能:
目的在于建构按钮超连结的参数。当点了状态再点票种,或是点了票种再点状态时,要同时套用两个参数。
app/helpers/admin/event_registrations_helper.rb
module Admin::EventRegistrationsHelper
+
+ def registration_filters(options)
+ params.permit(:status, :ticket_id).merge(options)
+ end
+
end
筛选资料(多选)
使用核选方框。
实务上,单选和多选的作法不太会混用,所以这里会注解掉上一节的单选接口(用if false去掉)
使用的HTML tag:
1. form_tag(url, method)
2. check_box_tag(name, value, checked="false")
controller中:因为是多项条件的筛选。应当是where内用and, or对参数进行整合的运用。
@registrations = @event.registrations.includes(:ticket).order("id DESC").page(params[:page]).per(20)
if Array(params[:statuses]).any?
@registrations = @registrations.where(:status => params[:statuses])
end
if Array(params[:ticket_ids]).any?
@registrations = @registrations.where(:ticket_id => params[:ticket_ids])
end
见⬆️, 经过2次if的条件筛选得到一个查询语法,如:
SELECT * FROM "registrations" WHERE "registrations"."event_id" = 32 AND "registrations"."status" = 'pending' AND "registrations"."ticket_id" IN (7, 9) ORDER BY id DESC LIMIT 20 OFFSET 0
不理解的是Array(params[..])?
答案:
因为,请求参数中包括"statuses"=>["pending"], "ticket_ids"=>["7", "9"], 它们的值是2个Array,它们是在check_box_tag中定义的name="statuses[]"和name="ticket_ids[]"。
所以,在controller中需要用判断这2个Array值是否存在,使用Array(params[...]), 进一步确保传进来的参数值是数组。
另外,直接写params[...]作为if的条件,来判断是否存在也可以。
时间区间筛选
date_field(object_name, method, options={})
返回一个text_field 类型是date。
options包括 value:"1984-05-11", min: Date.today, max: "2099-10-11"属性。
date_filed_tag(name, value=nil, options={})
options包括,max, min, 和text_field_tag相同的参数如:disabled,
view:
<p>
报名日期:<%= date_field_tag :start_on, params[:start_on] %>~<%= date_field_tag :end_on, params[:end_on] %>
</p>
controller:
if params[:start_on].present?
@registrations = @registrations.where("created_at >= ?", Date.parse(params[:start_on]).beginning_of_day)
end
传递进来的参数是"2018-01-01"的字符串。需要使用parse()转化为日期,然后用beginning_of_day转化为当前设置的时区的时间。
present?看一个对象是否存在。!blank?
资料比对筛选
controller:
if params[:registration_id].present?
@registrations = @registrations.where(:id => params[:registration_id].split(","))
end
view:
text_field_tag :registration_id, params[:registration_id], :placeholder => "报名编号,可用,号区隔", :class => "form-control"
关键字搜寻,使用 Ransack (3900✨)
to search a place very thoroughly, ofen making it untidy.
使用Ransack创建既简单又先进的搜索表格form。支持Rails5.0以上。
ransack 会用数据库的 LIKE 语法来做搜寻,虽然用起来方便,但它会逐笔检查资料是否符合,而不会使用数据库的索引。如果数据量非常多有上万笔以上,搜寻效能就会不满足我们的需要。这时候会改安装专门的全文搜寻引擎,例如 Elasticsearch,这是大数据等级的。
安装即用gem 'ransack',
简单使用(复杂使用没有看)
注意:
- 搜索参数的默认params key是 :q ,
- form_for -> search_form_for, 验证一个Ransack::Search object 会被传给search_form_for
- ActiveRecord::Relation methods不再delegated通过搜索对象。你可以通过使用Ransack#result 搜索结果
在controller中:
@q = Person.ransack(params[:q])
@people = @q.result(distinct: true) #使用distinct:true去掉重复的查询结果。
如果使用关联的表格列:
@q = Person.ransack(params[:q])
@people = @q.result.includes(:articles).page(params[:page]).to_a.uniq
#使用to_a.uniq移除重复,也可以在view中实现。
在view中:
又2个helper方法sort_link,search_form_for
Ransack's search_form_for
helper replaces form_for
for creating the view search form
<%= search_form_for @q, url: admin_event_registrations_path(@event) do |f| %>
<P><%= f.search_field :name_cont, :placeholder => "姓名", class: "form-control"%></p>
<P><%= f.search_field :email_cont, :placeholder => "E-mail", class: "form-control"%></p>
:name_cont中的cont是contains包括,这是search predicates搜索谓语。详见:list , wiki
什么是搜索谓语?
在Ransack搜索中, Predicates用于决定匹配什么信息。例如:cont predicate 会核查是否一个属性包含一个值,通过使用一个wildcard query(通配符查询)。
例子:
> User.ransack(email_cont: "candy@")
=> Ransack::Search<class: User, base: Grouping <conditions: [Condition <attributes: ["email"], predicate: cont, values: ["candy@"]>], combinator: and>>
> User.ransack(email_cont: "candy@").result
User Load (0.2ms) SELECT "users".* FROM "users" WHERE ("users"."email" LIKE '%candy@%')
=> #<ActiveRecord::Relation []>
> User.ransack(email_cont: "candy@").result.to_sql
=> "SELECT \"users\".* FROM \"users\" WHERE (\"users\".\"email\" LIKE '%candy@%')"
可以和or, and 连用:
>> User.ransack(first_name_or_last_name_cont: 'Rya').result.to_sql
=> SELECT "users".* FROM "users" WHERE ("users"."first_name" LIKE '%Rya%'
OR "users"."last_name" LIKE '%Rya%')
也可使用关联,假设User has_one Account, Account 有属性foo, bar:
>> User.ransack(account_foo_or_account_bar: "var").result.to_sql
=> SELECT * FROM "users" INNER JOIN accounts ON account.user_id = users.id WHERE( "accounts.foo LIKE '%var%' OR accounts.bar LIKE '%var%')
注意⚠️:对一个不存在的属性使用a predicate 会失败,where子句相当于不存在。
eq(equals)
eq predicate returns all records where a field is exactly equal to a given value; 相反的有not_eq
matches
匹配查询所有记录并返回, 相反的有does_not_match
使用LIKE 'xxx',精确匹配, 而contain使用 LIKE '%xxx%'
lt (less than)
gt(greater than)
gteq(greater than or equal to)
Iteq(less than or equal to)
in
>> User.ransack(age_in: 20..25)
>> User.ransack(age_in: [20, 21, 22, 23])
上面都是和数字相关的predicate
cont_all(contains all)
city_cont_all: %w(Grand Rapids)必须包括所有关键字才满足条件,生成
WHERE (("users"."city" LIKE '%Grand%' AND "users"."city" LIKE '%Rapids%'))
not_cont_all
cont_any( contains any)
first_name_cont_any: %w(Rya Lis)) 包括任意关键字即可 ,生成
WHERE (("users"."first_name" LIKE '%Rya%' OR "users"."first_name" LIKE '%Lis%'))
not_cont_any
start(starts with)
LIKE "%xx" 开头是xxx, 类似正则表达式/^xxx/
end(ends with)
LIKe "xx%" 结尾是xxx, 类似正则表达式/xxx$/
true , false
The false
predicate returns all records where a field is false.
>> User.ransack(awesome_false: '1').result.to_sql
=> SELECT "users".* FROM "users" WHERE ("users"."awesome" = 'f')
present , blank
>> User.ransack(first_name_present: '1').result.to_sql
=> SELECT "users".* FROM "users" WHERE (("users"."first_name" IS NOT NULL AND "users"."first_name" != ''))
null
>> User.ransack(first_name_null: 1).result.to_sql
=> SELECT "users".* FROM "users" WHERE "users"."first_name" IS NULL
URL parameter structure
Parameters: {"utf8"=>"✓", "q"=>{"name_cont"=>"", "email_cont"=>"cand"}, "registration_id"=>"", "statuses"=>["pending"], "start_on"=>"", "end_on"=>"", "commit"=>"送出筛选", "event_id"=>"hahaha-meetup"}
User.ransack(params[:q]) , 搜索参数被传入到ransack内是一个hash结构。q[:email_count]= "cand"
如果使用JavaScript来创建一个URL, 一个匹配的查询:
$.ajax({
url: "/users.json",
data: {
q: {
first_name_cont: "pete",
last_name_cont: "jack",
s: "created_at desc"
}
},
success: function (data){
console.log(data);
}
});
软删除和版本控制
- 在实际运作的网站中,用户可能会不小心删除资料, 用户可能会透过客服请求管理员进行复原。
- 针对重要的资料,建立追踪和稽核的机制。
软删除(Soft Deletion):不真的删除这一笔资料,常见的作法是增加一个删除的标记字段(例如 deleted_at
字段),如果被标记删除了,那就不要显示出来即可。
版本控管:建立一个 Version Model 来存储编修纪录。如果本来的资料被删除或修改,则会复制资料到这个 Model 去。 使用 paper_trail gem
Paper_trail 一个流行的版本控制gem (5000✨)
- 1.b. Installation
- 1.c. Basic Usage
- 1.d. API Summary (这是各个方法的介绍,具体如何用见3working with versions)
- 1.e. Configuration
当一个类,如Registration在model层加上has_paper_trail,就意味它加入了version control版本控制。
Version数据库根据属性item_type找到Registration, 根据属性item_id找到它的对应记录。
例子:
假设一条记录registration,经过3次数据update。那么在Version中同步insert into 3条记录
INSERT INTO "versions" ("item_type", "item_id", "event", "object", "created_at", "object_changes") VALUES (?, ?, ?, ?, ?, ?)
event字段储存的是create, update, destroy等方法。(也可以客制化event names)
object字段储存未改的记录,
object_changes储存记录更新或改变。
使用registration.versions方法 得到这条记录的所有之前的版本信息,返回一个数组集合,如果没有版本变化返回空数组。
#<ActiveRecord::Associations::CollectionProxy
[#<PaperTrail::Version id: 6, item_type: "Registration", item_id: 1004, event: "update", whodunnit: nil, object: "...", created_at: "...", object_changes: "...">,
#<PaperTrail::Version id: 7, item_type: "Registration", item_id: 1004, event: "update", whodunnit: nil, object: "...", created_at: "...", object_changes: "...">]
>
使用r = registrations.versions.last方法,得到最近一次的version变化。
r.event 等方法获得对应的字段的值。
r.whodunnit 得到current_user的🆔,需要先设置set_paper_trail_whodunnit 回调在admin_controller.rb中。
r.reify 让r实例化,把Version的记录转化为Registration的记录。(如果是create event返回nil)
r.reify.name 看实例化后的registration的属性值
r.reify.save 让Registration中的记录恢复到这个版本
2. Limiting What is Versioned, and When
- 2.a. Choosing Lifecycle Events To Monitor
- 2.b. Choosing When To Save New Versions
- 2.c. Choosing Attributes To Monitor
- 2.d. Turning PaperTrail Off(不同的开关版本控制的方法)
- 2.e. Limiting the Number of Versions Created (限制一条记录的版本控制记录数量)
监听Events的生命周期。
has_paper_trail on:[:update]
可以限制使用callback,默认的有4个on: [:create, :destroy, :touch, :update]
versions.event列的值默认有三个,create, destroy, update(包括touch, update两个回调)
使用if, unless来设置什么条件来保存新的version
class Translation < ActiveRecord::Base
has_paper_trail if: Proc.new { |t| t.language_code == 'US' },
unless: Proc.new { |t| t.type == 'DRAFT' }
end
使用Attributes来Monitor监听。
ignore, only, skip可以设置attributes的监听取舍。
比如has_paper_trail ignore: [:title, :description], 则这记录仅变化2个属性的值,不会增加一条version。
ignore, only也接受Hash参数,这样就可以使用块变量了,如:
has_paper_trail only: { title: Proc.new { |obj| !obj.title.blank? } }
当title不为空,并变化,增加一条version record。其他属性不版本控制。
skip除了ignore的功能,还有一个功能:其他原因创建的version记录, 属性不会留存( 不懂😢 )
- 3.a. Reverting And Undeleting A Model
- 3.b. Navigating Versions
- 3.c. Diffing Versions
- 3.d. Deleting Old Versions(删除旧版本,用sql,或delete_all方法)
这章节介绍了具体的使用:如何定位到一个版本,然后进行相关操作。
3b
一条记录.paper_trail.previous_version
previou_version, next_version 它们默认包括了reify方法.
一条记录.live? #返回boolean,如果这条记录对象是储存在加版本控制的model中的就返回true,如果是储存在Version中的就返回false。
3c 区别版本
在开始使用rails g paper_trail:install --with-changes中的--with-change option会增加一个column, object_changes
每次version update都会把变化的属性值存入这个字段,可以使用version.changeset方法来检索它
widget = Widget.create name: 'Bob'
widget.versions.last.changeset
# {
# "name"=>[nil, "Bob"],
# "created_at"=>[nil, 2015-08-10 04:10:40 UTC],
# "updated_at"=>[nil, 2015-08-10 04:10:40 UTC],
# "id"=>[nil, 1]
# }
<% version.changeset.each do |key, value| %>
<li><%= key %>从<%= value[0] || "'无'"%>改成<%= value[1]%></li>
<% end %>