the_grid 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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