the_grid 1.0.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,48 @@
1
+ module TheGrid
2
+ class Api::Command
3
+
4
+ def self.find(cmd)
5
+ @@commands ||= {}
6
+ @@commands[cmd] ||= build(cmd)
7
+ end
8
+
9
+ def self.register_lookup_scope(scope)
10
+ scopes.unshift(scope).uniq!
11
+ end
12
+
13
+ def self.scopes
14
+ @@scopes ||= ["the_grid/api/command"]
15
+ end
16
+
17
+ def self.build(cmd)
18
+ scope = scopes.detect do |scope|
19
+ "#{scope}/#{cmd}".camelize.constantize rescue nil
20
+ end
21
+ raise ArgumentError, %{ Command "#{cmd}" is unknown } if scope.nil?
22
+ "#{scope}/#{cmd}".camelize.constantize.new
23
+ end
24
+
25
+ def execute_on(relation, params)
26
+ run_on(relation, configure(relation, params))
27
+ end
28
+
29
+ def batch?
30
+ @is_batch ||= self.class.name.demodulize.starts_with?('Batch')
31
+ end
32
+
33
+ def contextualize(relation, params)
34
+ {}
35
+ end
36
+
37
+ protected
38
+
39
+ def run_on(relation, params)
40
+ raise "Method \"#{inspect}::run_on\" should be implemented by child class"
41
+ end
42
+
43
+ def configure(relation, params)
44
+ params
45
+ end
46
+
47
+ end
48
+ end
@@ -0,0 +1,52 @@
1
+ module TheGrid
2
+ class Api
3
+ attr_reader :relation, :options
4
+
5
+ def initialize(relation)
6
+ @relation = relation
7
+ @options = { :delegated_commands => {} }
8
+ end
9
+
10
+ def delegate(commands)
11
+ options[:delegated_commands].merge! commands.stringify_keys
12
+ end
13
+
14
+ def compose!(params)
15
+ configure(params).fetch(:cmd).each do |cmd|
16
+ @relation = run_command!(cmd, params) unless command(cmd).batch?
17
+ end
18
+ end
19
+
20
+ def run_command!(name, params)
21
+ @options.merge! command(name).contextualize(@relation, params)
22
+
23
+ if command_delegated?(name)
24
+ assoc_name = options[:delegated_commands][name.to_s]
25
+ assoc = @relation.reflections[assoc_name].klass.scoped
26
+ @relation.merge command(name).execute_on(assoc, params)
27
+ else
28
+ command(name).execute_on(@relation, params)
29
+ end
30
+ end
31
+
32
+ protected
33
+
34
+ def command(type)
35
+ ::TheGrid::Api::Command.find(type)
36
+ end
37
+
38
+ def command_delegated?(cmd)
39
+ options[:delegated_commands].has_key?(cmd.to_s)
40
+ end
41
+
42
+ def configure(params)
43
+ self.delegate(params[:delegate]) if params[:delegate]
44
+ params.tap do |o|
45
+ o[:cmd] = Array.wrap(o[:cmd])
46
+ o[:cmd].unshift(:paginate) unless params[:per_page] === false
47
+ o[:cmd].uniq!
48
+ end
49
+ end
50
+
51
+ end
52
+ end
@@ -0,0 +1,96 @@
1
+ module TheGrid
2
+ class Builder::Context
3
+ attr_reader :columns, :options, :scope, :name
4
+
5
+ def initialize(options = {}, &dsl)
6
+ @scope = options.delete(:scope)
7
+ @options = options
8
+ @columns = { :id => {:hidden => true} }
9
+
10
+ self.instance_eval(&dsl)
11
+ end
12
+
13
+ def column(name, attributes = {}, &block)
14
+ find_or_build_column(name).tap do |column|
15
+ column.merge! attributes
16
+ column[:as] = block if block_given?
17
+ end
18
+ end
19
+
20
+ def method_missing(method_name, *args, &block)
21
+ if @scope.respond_to?(method_name)
22
+ @scope.send(method_name, *args, &block)
23
+ elsif method_name.to_s.ends_with?("ble_columns")
24
+ feature = method_name.to_s.chomp("_columns")
25
+ mark_columns_with(feature.to_sym, args)
26
+ @options[method_name.to_sym] = args
27
+ else
28
+ @options[method_name] = args.size == 1 ? args.first : args
29
+ end
30
+ end
31
+
32
+ def scope_for(scope_name, attributes = {}, &block)
33
+ name = attributes.delete(:as) || scope_name
34
+ column name, attributes.merge(:as => Builder::Context.new(:scope => scope, &block), :scope_name => scope_name)
35
+ end
36
+
37
+ def visible_columns
38
+ columns.each_with_object({}) do |column, vc|
39
+ name, options = column
40
+ vc[name] = options.except(:as, :if, :unless) unless options[:hidden]
41
+ vc[name] = options[:as].visible_columns if options[:as].respond_to?(:visible_columns)
42
+ end
43
+ end
44
+
45
+ def assemble(records)
46
+ records.map{ |record| assemble_row_for(record) }
47
+ end
48
+
49
+ protected
50
+
51
+ def find_or_build_column(name)
52
+ @columns[name.to_sym] ||= {}
53
+ end
54
+
55
+ def mark_columns_with(feature, column_names)
56
+ column_names.each do |name|
57
+ find_or_build_column(name).store(feature, true)
58
+ end
59
+ end
60
+
61
+ def assemble_row_for(record)
62
+ columns.each_with_object({}) do |column, row|
63
+ name, options = column
64
+ row[name] = assemble_column_for(record, name, options)
65
+ end
66
+ end
67
+
68
+ def assemble_column_for(record, name, options)
69
+ formatter = options[:as]
70
+
71
+ if formatter.respond_to?(:call)
72
+ formatter.call(record)
73
+ elsif formatter.is_a? Symbol
74
+ record.send(formatter)
75
+ elsif formatter.respond_to?(:assemble)
76
+ formatter.assemble(record.send(options[:scope_name])) if may_assemble?(record, options)
77
+ else
78
+ record.send(name)
79
+ end
80
+ end
81
+
82
+ def may_assemble?(record, options)
83
+ condition = options[:if] || options[:unless]
84
+
85
+ if condition.is_a? Symbol
86
+ result = assemble_column_for(record, condition, columns[condition])
87
+ elsif condition.respond_to?(:call)
88
+ result = condition.call(record)
89
+ else
90
+ result = true
91
+ end
92
+ options[:unless].present? ? !result : result
93
+ end
94
+
95
+ end
96
+ end
@@ -0,0 +1,37 @@
1
+ module TheGrid
2
+ class Builder::Json
3
+ cattr_accessor :prettify_json
4
+ attr_reader :api, :context
5
+
6
+ def initialize(relation, context)
7
+ @api = TheGrid::Api.new(relation)
8
+ @context = context
9
+ end
10
+
11
+ def assemble_with(params)
12
+ options = params.merge context.options
13
+ api.compose!(options)
14
+ stringify as_json_with(options)
15
+ rescue ArgumentError => error
16
+ stringify as_json_message('error', error.message)
17
+ end
18
+
19
+ private
20
+
21
+ def stringify(json_hash)
22
+ self.class.prettify_json ? JSON.pretty_generate(json_hash) : json_hash.to_json
23
+ end
24
+
25
+ def as_json_with(options)
26
+ {}.tap do |json|
27
+ json[:meta], json[:columns] = context.options.except(:delegate, :search_over), context.visible_columns if options[:with_meta]
28
+ json[:max_page] = api.options[:max_page]
29
+ json[:items] = context.assemble(api.relation)
30
+ end
31
+ end
32
+
33
+ def as_json_message(status, message)
34
+ {:status => status, :message => message}
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,61 @@
1
+ module TheGrid
2
+ class Builder
3
+ private_class_method :new
4
+
5
+ def self.call(template)
6
+ source = if template.source.empty?
7
+ File.read(template.identifier)
8
+ else
9
+ template.source
10
+ end
11
+
12
+ %{
13
+ ::TheGrid::Builder.assemble(:view_type => ::TheGrid::Builder::Json, :scope => self) {
14
+ #{source}
15
+ }
16
+ }
17
+ end
18
+
19
+ def self.assemble(options, &block)
20
+ new(options, &block)
21
+ end
22
+
23
+ def initialize(options, &block)
24
+ options.assert_valid_keys(:scope, :view_type)
25
+
26
+ @_scope = options.delete(:scope)
27
+ @_view_type = options.delete(:view_type)
28
+
29
+ copy_instance_variables_from(@_scope) if @_scope
30
+ self.instance_eval(&block)
31
+ end
32
+
33
+ def grid_for(relation, options = {}, &block)
34
+ context = Context.new(options.merge(:scope => @_scope), &block)
35
+ @_view_handler = @_view_type.new(relation, context)
36
+ end
37
+
38
+ def assemble(&block)
39
+ @_view_handler.assemble_with(@_scope.params, &block)
40
+ end
41
+
42
+ def method_missing(name, *args, &block)
43
+ if @_scope.respond_to?(name)
44
+ @_scope.send(name, *args, &block)
45
+ else
46
+ super
47
+ end
48
+ end
49
+
50
+ def to_s; assemble;end
51
+ def to_str; assemble;end
52
+
53
+ private
54
+
55
+ def copy_instance_variables_from(object)
56
+ vars = object.instance_variables.map(&:to_s)
57
+ vars.each { |name| instance_variable_set(name.to_sym, object.instance_variable_get(name)) }
58
+ end
59
+
60
+ end
61
+ end
@@ -0,0 +1,18 @@
1
+ module TheGrid
2
+ class Config
3
+ attr_accessor :default_max_per_page, :prettify_json, :commands_lookup_scopes
4
+
5
+ def initialize
6
+ self.commands_lookup_scopes = []
7
+ self.prettify_json = false
8
+ end
9
+
10
+ def apply
11
+ self.commands_lookup_scopes.flatten.each{ |s| Api::Command.register_lookup_scope(s) }
12
+ Api::Command.find(:paginate).default_per_page = self.default_max_per_page
13
+ Builder::Json.prettify_json = self.prettify_json
14
+ end
15
+ end
16
+
17
+ ActionView::Template.register_template_handler :grid_builder, ::TheGrid::Builder if defined?(ActionView::Template)
18
+ end
@@ -0,0 +1,3 @@
1
+ module TheGrid
2
+ VERSION = "1.0.7"
3
+ end
data/lib/the_grid.rb ADDED
@@ -0,0 +1,18 @@
1
+ require 'the_grid/version'
2
+ require 'the_grid/api'
3
+ require 'the_grid/api/command'
4
+ require 'the_grid/builder'
5
+ require 'the_grid/config'
6
+ Dir.chdir(File.dirname(__FILE__)) do
7
+ Dir['the_grid/builder/**/*.rb', 'the_grid/api/command/**/*.rb'].each{ |f| require f }
8
+ end
9
+
10
+ module TheGrid
11
+ def self.configure
12
+ Config.new.tap{ |c| yield c }.apply
13
+ end
14
+
15
+ def self.build_for(relation)
16
+ Api.new(relation)
17
+ end
18
+ end
@@ -0,0 +1,41 @@
1
+ require 'spec_helper'
2
+
3
+ describe TheGrid::Api::Command::BatchRemove do
4
+ let(:table) { double(:primary_key => double.as_null_object) }
5
+ let(:relation) { double(:table => table).tap{ |r| r.stub(:scoped => r, :where => r) } }
6
+
7
+ it "raise exception when item_ids is blank" do
8
+ expect{
9
+ subject.execute_on(relation, :item_ids => [])
10
+ }.to raise_error ArgumentError
11
+ end
12
+
13
+ it "is a batch command" do
14
+ subject.batch?.should be_true
15
+ end
16
+
17
+ context "when item_ids is present" do
18
+ let(:item_ids) { [1, 2, 'non-int', 3, 4, '5'] }
19
+ let(:int_ids) { item_ids.reject{ |id| id.to_i <= 0 } }
20
+
21
+ before(:each) { relation.stub(:destroy_all => int_ids) }
22
+ after(:each) { subject.execute_on(relation, :item_ids => item_ids) }
23
+
24
+ it "remove items based on by primary key" do
25
+ relation.table.primary_key.should_receive(:in)
26
+ end
27
+
28
+ it "reject non-integer ids" do
29
+ relation.table.primary_key.should_receive(:in).with(int_ids)
30
+ end
31
+
32
+ it "remove only records with specified ids" do
33
+ relation.should_receive(:where).with(table.primary_key.in(int_ids))
34
+ end
35
+
36
+ it "destroy records" do
37
+ relation.should_receive(:destroy_all)
38
+ end
39
+ end
40
+
41
+ end
@@ -0,0 +1,44 @@
1
+ require 'spec_helper'
2
+
3
+ describe TheGrid::Api::Command::BatchUpdate do
4
+ let(:table) { double(:primary_key => double(:name => 'id').as_null_object) }
5
+ let(:relation) { double(:table => table).tap{ |r| r.stub(:scoped => r, :where => r) } }
6
+
7
+ it "raise exception when items is blank" do
8
+ expect{
9
+ subject.execute_on(relation, :items => [])
10
+ }.to raise_error ArgumentError
11
+ end
12
+
13
+ it "is a batch command" do
14
+ subject.batch?.should be_true
15
+ end
16
+
17
+ context "when items is present" do
18
+ let(:non_valid_items) { 2.times.map{ |i| {'id' => "string_#{i}", 'name' => "test_#{i}"} } }
19
+ let(:valid_items) { 4.times.map{ |i| {'id' => i +1 , 'name' => "test_#{i}"} } }
20
+ let(:valid_ids) { valid_items.map{ |r| r['id'] } }
21
+ let(:records) { valid_items.map{ |r| double(r.merge :update_attributes => true) } }
22
+
23
+ before(:each) { relation.stub(:where => records) }
24
+ after(:each) { subject.execute_on(relation, :items => valid_items + non_valid_items) }
25
+
26
+ it "remove items based on by primary key" do
27
+ relation.table.primary_key.should_receive(:in)
28
+ end
29
+
30
+ it "reject items with non-integer ids" do
31
+ relation.table.primary_key.should_receive(:in).with(valid_ids)
32
+ end
33
+
34
+ it "remove only records with specified ids" do
35
+ relation.should_receive(:where).with(table.primary_key.in(valid_ids))
36
+ end
37
+
38
+ it "update attributes with given data" do
39
+ rows = records.index_by(&:id)
40
+ valid_items.each{ |data| rows.fetch(data['id']).should_receive(:update_attributes).with(data.except('id')) }
41
+ end
42
+ end
43
+
44
+ end
@@ -0,0 +1,89 @@
1
+ require 'spec_helper'
2
+
3
+ shared_examples "for a range filter" do
4
+ let(:filters){ { :field => filter } }
5
+
6
+ context "when both boundaries are specified" do
7
+ it "adds 'from' filter" do
8
+ relation.table[:field].should_receive(:gteq).with(value[:from])
9
+ end
10
+
11
+ it "adds 'to' filter" do
12
+ relation.table[:field].should_receive(:lteq).with(value[:to])
13
+ end
14
+ end
15
+
16
+ context "when left boundary is missed" do
17
+ before(:each){ filters[:field] = filter.except(:from) }
18
+
19
+ it "adds 'to' filter" do
20
+ relation.table[:field].should_receive(:lteq).with(value[:to])
21
+ end
22
+
23
+ it "does not add 'from' filter" do
24
+ relation.table[:field].should_not_receive(:gteq)
25
+ end
26
+ end
27
+
28
+ context "when right boundary is missed" do
29
+ before(:each){ filters[:field] = filter.except(:to) }
30
+
31
+ it "adds 'from' filter " do
32
+ relation.table[:field].should_receive(:gteq).with(value[:from])
33
+ end
34
+
35
+ it "does not add 'to' filter" do
36
+ relation.table[:field].should_not_receive(:lteq)
37
+ end
38
+ end
39
+ end
40
+
41
+ describe TheGrid::Api::Command::Filter do
42
+ let(:table) { double(:blank? => false).as_null_object }
43
+ let(:relation) { double(:table => table).tap{ |r| r.stub(:where => r) } }
44
+
45
+ after(:each) { subject.execute_on(relation, :filters => filters) }
46
+
47
+ context "when filters are missed" do
48
+ let(:filters){ Hash.new }
49
+
50
+ it "returns the same relation object" do
51
+ relation.should_not_receive(:where)
52
+ end
53
+ end
54
+
55
+ context "when filters with primary values" do
56
+ let(:filters){ { :id => [1, 2], :state => "test" } }
57
+
58
+ it "changes relation conditions" do
59
+ relation.should_receive(:where)
60
+ end
61
+
62
+ it "filters by array of values" do
63
+ relation.table[:id].should_receive(:in).with(filters[:id])
64
+ end
65
+
66
+ it "filters by value" do
67
+ relation.table[:state].should_receive(:eq).with(filters[:state])
68
+ end
69
+ end
70
+
71
+ context "when filters with date range" do
72
+ let(:filter) { {:from => '2013-02-12 12:20:21', :to => '2013-05-12 13:20:01', :type => :date } }
73
+ let(:value) { Hash[filter.except(:type).map{ |k, v| [k, v.to_time] }] }
74
+ include_examples "for a range filter"
75
+ end
76
+
77
+ context "when filters with time range" do
78
+ let(:filter) { {:from => 2.days.ago.to_f, :to => Time.now.to_f, :type => :time } }
79
+ let(:value) { Hash[filter.except(:type).map{ |k, v| [k, Time.at(v)] }] }
80
+ include_examples "for a range filter"
81
+ end
82
+
83
+ context "when filters with non-date range" do
84
+ let(:filter) { {:from => 10, :to => 20} }
85
+ let(:value) { filter }
86
+ include_examples "for a range filter"
87
+ end
88
+
89
+ end
@@ -0,0 +1,44 @@
1
+ require 'spec_helper'
2
+
3
+ describe TheGrid::Api::Command::Paginate do
4
+ let(:relation){ double("ActiveRecord::Relation", :count => 25).as_null_object }
5
+
6
+ context "when options are missed" do
7
+ after(:each){ subject.execute_on(relation, {}) }
8
+
9
+ it "returns first page" do
10
+ relation.should_receive(:offset).with(0)
11
+ end
12
+
13
+ it "returns default amount of items" do
14
+ relation.should_receive(:limit).with(subject.class.default_per_page)
15
+ end
16
+ end
17
+
18
+ context "when options are specified" do
19
+ after(:each){ subject.execute_on(relation, :page => 3, :per_page => 5) }
20
+
21
+ it "returns specified page" do
22
+ relation.should_receive(:offset).with(10)
23
+ end
24
+
25
+ it "returns specified amount of items" do
26
+ relation.should_receive(:limit).with(5)
27
+ end
28
+ end
29
+
30
+ context "when calculates max page" do
31
+ it "should use default per_page option" do
32
+ subject.calculate_max_page_for(relation, {}).should eql (25.0 / subject.class.default_per_page).ceil
33
+ end
34
+
35
+ it "should respect specified per_page option" do
36
+ subject.calculate_max_page_for(relation, :per_page => 15).should eql 2
37
+ end
38
+ end
39
+
40
+ it "calculates max page when prepares context" do
41
+ subject.contextualize(relation, :page => 1, :per_page => 15).fetch(:max_page).should eql 2
42
+ end
43
+
44
+ end
@@ -0,0 +1,64 @@
1
+ require 'spec_helper'
2
+
3
+ describe TheGrid::Api::Command::Search do
4
+ let(:columns) {{ :name => string_column, :status => string_column, :id => double(:type => :int) }}
5
+ let(:relation) { double(:columns_hash => columns, :column_names => columns.keys, :table => columns).as_null_object }
6
+
7
+ after(:each) { subject.execute_on(relation, options) }
8
+
9
+ context "when query or search_over is missed" do
10
+ let(:options) { Hash.new }
11
+
12
+ it "does not search if query is missed" do
13
+ relation.should_not_receive(:where)
14
+ end
15
+
16
+ it "does not search over nested relations if search_over is missed" do
17
+ relation.should_not_receive(:reflections)
18
+ end
19
+ end
20
+
21
+ context "when searchable_columns is missed" do
22
+ let(:options) {{ :query => "test" }}
23
+
24
+ it "search over own columns" do
25
+ relation.should_receive(:columns_hash)
26
+ end
27
+
28
+ it "does not search over non-string columns" do
29
+ relation.table[:id].should_not_receive(:matches)
30
+ end
31
+
32
+ it "combine searchable conditions by or" do
33
+ relation.table[:name].should_receive(:or).with(relation.table[:status])
34
+ end
35
+ end
36
+
37
+ context "when options are valid" do
38
+ let(:options) {{ :query => "test", :searchable_columns => [:name] }}
39
+
40
+ it "search by query" do
41
+ relation.should_receive(:where)
42
+ end
43
+
44
+ it "search only by specified columns" do
45
+ relation.table[:status].should_not_receive(:matches)
46
+ relation.table[:id].should_not_receive(:matches)
47
+ end
48
+
49
+ it "search using SQL LIKE" do
50
+ relation.table[:name].should_receive(:matches).with("%#{options[:query]}%")
51
+ end
52
+ end
53
+
54
+ context "when search over nested relations" do
55
+ pending "logic is too complicated; maybe will be rewritten"
56
+ end
57
+
58
+ def string_column
59
+ double(:type => :string).tap do |c|
60
+ c.stub(:matches => c, :or => c)
61
+ end
62
+ end
63
+
64
+ end
@@ -0,0 +1,23 @@
1
+ require 'spec_helper'
2
+
3
+ describe TheGrid::Api::Command::Sort do
4
+ let(:table) { double(:name => "table_for_sort", :present? => true).as_null_object }
5
+ let(:relation) { double(:table_name => table.name, :table => table) }
6
+ let(:options) { {:field => "name", :order => "desc" } }
7
+
8
+ after(:each){ subject.execute_on(relation, options) }
9
+
10
+ it "sort by asc if order invalid" do
11
+ options[:order] = 'wrong order'
12
+ relation.should_receive(:order).with("#{table.name}.#{options[:field]} asc")
13
+ end
14
+
15
+ it "sort by specified order" do
16
+ relation.should_receive(:order).with("#{table.name}.#{options[:field]} #{options[:order]}")
17
+ end
18
+
19
+ it "does not prepend field with table name if field is an alias" do
20
+ table.stub(:present? => false)
21
+ relation.should_receive(:order).with("#{options[:field]} #{options[:order]}")
22
+ end
23
+ end
@@ -0,0 +1,44 @@
1
+ require 'spec_helper'
2
+
3
+ module GridCommands
4
+ class Sort < TheGrid::Api::Command::Sort; end
5
+ end
6
+
7
+ describe TheGrid::Api::Command do
8
+ subject{ TheGrid::Api::Command }
9
+ let(:commands_scope) { GridCommands }
10
+
11
+ it "can be executed on relation" do
12
+ subject.find(:paginate).should respond_to(:execute_on)
13
+ end
14
+
15
+ it "build command instance" do
16
+ subject.find(:paginate).should be_kind_of subject.const_get('Paginate')
17
+ end
18
+
19
+ it "build flyweight instances" do
20
+ subject.find(:paginate).object_id.should eql subject.find(:paginate).object_id
21
+ end
22
+
23
+ it "raise error if command not found" do
24
+ expect{ subject.find(:unknown_cmd) }.to raise_error ArgumentError
25
+ end
26
+
27
+ it "has only one scope for commands by default" do
28
+ subject.scopes.should eql [ subject.to_s.underscore ]
29
+ end
30
+
31
+ context "when register new scope" do
32
+ before(:each) { subject.register_lookup_scope commands_scope.to_s.underscore }
33
+ after(:each) { subject.scopes.shift() }
34
+
35
+ it "put scope at the top" do
36
+ subject.scopes.first.should eql commands_scope.to_s.underscore
37
+ end
38
+
39
+ it "build the first found command" do
40
+ subject.find(:sort).should be_kind_of commands_scope.const_get('Sort')
41
+ end
42
+ end
43
+
44
+ end