trk_datatables 0.1.0

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,10 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << 'test'
6
+ t.libs << 'lib'
7
+ t.test_files = FileList['test/**/*_test.rb']
8
+ end
9
+
10
+ task default: :test
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'trk_datatables'
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require 'irb'
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,15 @@
1
+ require 'trk_datatables/version'
2
+ # modules
3
+ require 'trk_datatables/preferences.rb'
4
+
5
+ require 'trk_datatables/base'
6
+ require 'trk_datatables/active_record'
7
+ require 'trk_datatables/dt_params'
8
+ require 'trk_datatables/column_key_options.rb'
9
+ require 'trk_datatables/render_html.rb'
10
+
11
+ # libs
12
+ require 'active_support/core_ext/hash/indifferent_access'
13
+ require 'active_support/core_ext/hash/keys'
14
+ require 'active_support/core_ext/string/inflections'
15
+ require 'active_support/core_ext/string/output_safety'
@@ -0,0 +1,110 @@
1
+ module TrkDatatables
2
+ class ActiveRecord < Base
3
+ # Global search. All columns are typecasted to string. Search string is
4
+ # splited by space and "and"-ed.
5
+ def filter_by_search_all(filtered_items)
6
+ conditions = @dt_params.search_all.split(' ').map do |search_string|
7
+ @column_key_options.searchable_and_global_search.map do |column_key_option|
8
+ filter_column_as_string column_key_option, search_string
9
+ end.reduce(:or) # any searchable column is 'or'-ed
10
+ end.reduce(:and) # 'and' for each search_string
11
+
12
+ filtered_items.where conditions
13
+ end
14
+
15
+ def filter_by_columns(filtered_items)
16
+ conditions = @dt_params.dt_columns.each_with_object([]) do |dt_column, cond|
17
+ next unless dt_column[:searchable] && dt_column[:search_value].present?
18
+
19
+ # check both params and configuration
20
+ column_key_option = @column_key_options[dt_column[:index]]
21
+ next if column_key_option[:column_options][ColumnKeyOptions::SEARCH_OPTION] == false
22
+
23
+ cond << build_condition_for_column(column_key_option, dt_column[:search_value])
24
+ end.reduce(:and) # 'and' for each searchable column
25
+
26
+ filtered_items.where conditions
27
+ end
28
+
29
+ def build_condition_for_column(column_key_option, search_value)
30
+ # nil is when we use action columns, usually not column searchable
31
+ return nil if column_key_option[:column_type_in_db].nil?
32
+
33
+ select_options = column_key_option[:column_options][ColumnKeyOptions::SELECT_OPTIONS]
34
+ if select_options.present?
35
+ filter_column_as_in(column_key_option, search_value)
36
+ elsif %i[date datetime integer float].include?(column_key_option[:column_type_in_db]) && \
37
+ search_value.include?(BETWEEN_SEPARATOR)
38
+ from, to = search_value.split BETWEEN_SEPARATOR
39
+ filter_column_as_between(column_key_option, from, to)
40
+ else
41
+ filter_column_as_string(column_key_option, search_value)
42
+ end
43
+ end
44
+
45
+ def filter_column_as_string(column_key_option, search_value)
46
+ search_value.split(' ').map do |search_string|
47
+ casted_column = ::Arel::Nodes::NamedFunction.new(
48
+ 'CAST',
49
+ [_arel_column(column_key_option).as(@column_key_options.string_cast)]
50
+ )
51
+ casted_column.matches("%#{search_string}%")
52
+ end.reduce(:and)
53
+ end
54
+
55
+ def filter_column_as_between(column_key_option, from, to)
56
+ from, to = _parse_from_to(from, to, column_key_option)
57
+ if from.present? && to.present?
58
+ _arel_column(column_key_option).between(from..to)
59
+ elsif from.present?
60
+ _arel_column(column_key_option).gteq(from)
61
+ elsif to.present?
62
+ _arel_column(column_key_option).lteq(to)
63
+ # else
64
+ # nil will result in true relation
65
+ end
66
+ end
67
+
68
+ def filter_column_as_in(column_key_option, search_value)
69
+ _arel_column(column_key_option).in search_value.split(MULTIPLE_OPTION_SEPARATOR)
70
+ end
71
+
72
+ def _parse_from_to(from, to, column_key_option)
73
+ case column_key_option[:column_type_in_db]
74
+ # when :integer, :float
75
+ # we do not need to cast from string since range will do automatically
76
+ when :date, :datetime
77
+ from = _parse_in_zone(from) if from.present?
78
+ to = _parse_in_zone(to) if to.present?
79
+ end
80
+ [from, to]
81
+ end
82
+
83
+ # rubocop:disable Rails/TimeZone
84
+ def _parse_in_zone(time)
85
+ # without rails we will parse without zone so make sure params are correct
86
+ Time.zone ? Time.zone.parse(time) : Time.parse(time)
87
+ end
88
+ # rubocop:enable Rails/TimeZone
89
+
90
+ def order_and_paginate_items(filtered_items)
91
+ filtered_items = order_items filtered_items
92
+ filtered_items = filtered_items.offset(@dt_params.dt_offset).limit(dt_per_page_or_default)
93
+ filtered_items
94
+ end
95
+
96
+ def order_items(filtered_items)
97
+ order_by = dt_orders_or_default.each_with_object([]) do |dt_order, queries|
98
+ column_key_option = @column_key_options[dt_order[:column_index]]
99
+ next if column_key_option[:column_options][ColumnKeyOptions::ORDER_OPTION] == false
100
+
101
+ queries << "#{column_key_option[:column_key]} #{dt_order[:direction]}"
102
+ end
103
+ filtered_items.order(Arel.sql(order_by.join(', ')))
104
+ end
105
+
106
+ def _arel_column(column_key_option)
107
+ column_key_option[:table_class].arel_table[column_key_option[:column_name]]
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,189 @@
1
+ module TrkDatatables
2
+ BETWEEN_SEPARATOR = ' - '.freeze
3
+ MULTIPLE_OPTION_SEPARATOR = '|'.freeze
4
+ DEFAULT_ORDER = [{ column_index: 0, direction: :desc }].freeze
5
+ DEFAULT_PAGE_LENGTH = 10
6
+
7
+ class Error < StandardError
8
+ end
9
+
10
+ class Base
11
+ include TrkDatatables::Preferences
12
+ attr_accessor :column_key_options
13
+
14
+ def initialize(view)
15
+ @view = view
16
+ @dt_params = DtParams.new view.params
17
+ @column_key_options = ColumnKeyOptions.new columns, global_search_columns
18
+
19
+ # if @dt_params.dt_columns.size != @column_key_options.size
20
+ # raise Error, "dt_columns size of columns is #{@dt_params.dt_columns.size} \
21
+ # but column_key_options size is #{@column_key_options.size}"
22
+ # end
23
+ end
24
+
25
+ # Get all items from db
26
+ #
27
+ # @example
28
+ # def all_items
29
+ # Post.joins(:users).published
30
+ # end
31
+ # @return [ActiveRecord::Relation]
32
+ def all_items
33
+ raise NotImplementedError, "You should implement #{__method__} method"
34
+ end
35
+
36
+ # Define columns of a table
37
+ # For simplest version you can notate column_keys as Array of strings
38
+ # @example
39
+ # def column
40
+ # %w[posts.id posts.status users.name]
41
+ # end
42
+ #
43
+ # When you need customisation of some columns, you need to define Hash of column_key => { column_options }
44
+ # @example
45
+ # def columns
46
+ # {
47
+ # 'posts.id': {},
48
+ # 'posts.status' => { search: false },
49
+ # 'users.name' => { order: false },
50
+ # }
51
+ # end
52
+ # @return Array of Hash
53
+ def columns
54
+ raise NotImplementedError, "You should implement #{__method__} method #{link_to_rdoc self.class, __method__}"
55
+ end
56
+
57
+ # Define columns that are not returned to page but only used as mathing for
58
+ # global search
59
+ # @example
60
+ # def global_search_columns
61
+ # %w[name email].map {|col| "users.#{col}" } + %w[posts.body]
62
+ # end
63
+ def global_search_columns
64
+ []
65
+ end
66
+
67
+ # Define page data
68
+ # @example
69
+ # def rows(page_items)
70
+ # page_items.map do |post|
71
+ # post_status = @view.content_tag :span, post.status, class: "label label-#{@view.convert_status_to_class post.status}"
72
+ # [
73
+ # post.id,
74
+ # post_status,
75
+ # @view.link_to(post.user.name, post.user)
76
+ # ]
77
+ # end
78
+ # end
79
+ def rows(_page_items)
80
+ raise NotImplementedError, "You should implement #{__method__} method"
81
+ end
82
+
83
+ def filter_by_search_all(_all)
84
+ raise 'filter_by_columns_is_defined_in_specific_orm'
85
+ end
86
+
87
+ def filter_by_columns(_all)
88
+ raise 'filter_by_columns_is_defined_in_specific_orm' \
89
+ "\n Extent from TrkDatatables::ActiveRecord instead of TrkDatatables::Base"
90
+ end
91
+
92
+ def order_and_paginate_items(_filtered_items)
93
+ raise 'order_and_paginate_items_is_defined_in_specific_orm'
94
+ end
95
+
96
+ # Returns dt_orders or default
97
+ # @return
98
+ # [
99
+ # { column_index: 0, direction: :desc },
100
+ # ]
101
+ def dt_orders_or_default
102
+ return @dt_orders_or_default if defined? @dt_orders_or_default
103
+
104
+ if @dt_params.dt_orders.present?
105
+ @dt_orders_or_default = @dt_params.dt_orders
106
+ set_preference :order, @dt_params.dt_orders
107
+ else
108
+ @dt_orders_or_default = get_preference(:order) || DEFAULT_ORDER
109
+ end
110
+ @dt_orders_or_default
111
+ end
112
+
113
+ def dt_per_page_or_default
114
+ return @dt_per_page_or_default if defined? @dt_per_page_or_default
115
+
116
+ @dt_per_page_or_default = \
117
+ if @dt_params.dt_per_page.present?
118
+ set_preference :per_page, @dt_params.dt_per_page
119
+ @dt_params.dt_per_page
120
+ else
121
+ get_preference(:per_page) || DEFAULT_PAGE_LENGTH
122
+ end
123
+ end
124
+
125
+ # Set params for columns. This is class method so you do not need datatable
126
+ # instance.
127
+ #
128
+ # @example
129
+ # link_to 'Published posts for my@email.com',
130
+ # posts_path(PostsDatatable.params('posts.status': :published,
131
+ # 'users.email: 'my@email.com')
132
+ #
133
+ # You can always use your params for filtering outside of datatable
134
+ # @example
135
+ # link_to 'Published posts for user1',
136
+ # posts_path(PostsDatatable.params_set('posts.status': :published).merge(user_id: user1.id))
137
+ def self.params_set(attr)
138
+ datatable = new OpenStruct.new(params: {})
139
+ result = {}
140
+ attr.each do |column_key, value|
141
+ value = value.join MULTIPLE_OPTION_SEPARATOR if value.is_a? Array
142
+ column_index = datatable.index_by_column_key column_key
143
+ result = result.deep_merge DtParams.param_set column_index, value
144
+ end
145
+ result
146
+ end
147
+
148
+ # We need this method publicly available since we use it for class method
149
+ # params_set
150
+ def index_by_column_key(column_key)
151
+ @column_key_options.index_by_column_key column_key
152
+ end
153
+
154
+ # Helper to populate column search from params, used in
155
+ # RenderHtml#thead
156
+ # @example
157
+ # @datatable.param_get('users.email')
158
+ def param_get(column_key)
159
+ column_index = index_by_column_key column_key
160
+ @dt_params.param_get column_index
161
+ end
162
+
163
+ # _attr is given by Rails template, prefix, layout... not used
164
+ def as_json(_attr = nil)
165
+ # get the value if it is not a relation
166
+ all_count = all_items.count
167
+ filtered_items = filter_by_search_all filter_by_columns all_items
168
+ ordered_paginated_filtered_items = order_and_paginate_items filtered_items
169
+ @dt_params.as_json(
170
+ all_count,
171
+ filtered_items.count,
172
+ rows(ordered_paginated_filtered_items)
173
+ )
174
+ end
175
+
176
+ def link_to_rdoc(klass, method)
177
+ "http://localhost:8808/docs/TrkDatatables/#{klass.name}##{method}-instance_method"
178
+ end
179
+
180
+ def render_html(search_link = nil, html_options = {})
181
+ if search_link.is_a? Hash
182
+ html_options = search_link
183
+ search_link = nil
184
+ end
185
+ render = RenderHtml.new(search_link, self, html_options)
186
+ render.result
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,165 @@
1
+ module TrkDatatables
2
+ # rubocop:disable ClassLength
3
+ class ColumnKeyOptions
4
+ include Enumerable
5
+
6
+ # All options that you can use for columns:
7
+ #
8
+ # search: if you want to enable global and column search, default is true
9
+ # order: `:asc` or `:desc` or `false`, default is `:desc`
10
+ # @code
11
+ # def columns
12
+ # {
13
+ # 'users.name': { search: false }
14
+ # }
15
+ SEARCH_OPTION = :search
16
+ ORDER_OPTION = :order
17
+ TITLE_OPTION = :title
18
+ SELECT_OPTIONS = :select_options
19
+ # this will load date picker
20
+ # SEARCH_OPTION_DATE_VALUE = :date
21
+ # SEARCH_OPTION_DATETIME_VALUE = :datetime
22
+ COLUMN_OPTIONS = [SEARCH_OPTION, ORDER_OPTION, TITLE_OPTION, SELECT_OPTIONS].freeze
23
+
24
+ STRING_TYPE_CAST_POSTGRES = 'VARCHAR'.freeze
25
+ STRING_TYPE_CAST_MYSQL = 'CHAR'.freeze
26
+ STRING_TYPE_CAST_SQLITE = 'TEXT'.freeze
27
+ STRING_TYPE_CAST_ORACLE = 'VARCHAR2(4000)'.freeze
28
+
29
+ DB_ADAPTER_STRING_TYPE_CAST = {
30
+ psql: STRING_TYPE_CAST_POSTGRES,
31
+ mysql: STRING_TYPE_CAST_MYSQL,
32
+ mysql2: STRING_TYPE_CAST_MYSQL,
33
+ sqlite: STRING_TYPE_CAST_SQLITE,
34
+ sqlite3: STRING_TYPE_CAST_SQLITE,
35
+ oracle: STRING_TYPE_CAST_ORACLE,
36
+ oracleenhanced: STRING_TYPE_CAST_ORACLE
37
+ }.freeze
38
+
39
+ attr_accessor :string_cast
40
+
41
+ # @return
42
+ # {
43
+ # column_key: :'users.name',
44
+ # column_options: { order: false, select_options: User.statuses },
45
+ # table_class: User,
46
+ # column_name: :name,
47
+ # column_type_in_db: :string,
48
+ # title: 'Name',
49
+ # html_options: { class: 'my-class' },
50
+ # }
51
+ def initialize(cols, global_search_cols)
52
+ # if someone use Array instead of hash, we will use first element
53
+ if cols.is_a? Array
54
+ cols = cols.each_with_object({}) do |column_key, hash|
55
+ hash[column_key.to_sym] = {}
56
+ end
57
+ end
58
+ _set_data(cols)
59
+ _set_global_search_cols(global_search_cols)
60
+ @string_cast = _determine_string_type_cast
61
+ end
62
+
63
+ def _set_data(cols)
64
+ @data = cols.each_with_object([]) do |(column_key, column_options), arr|
65
+ raise Error, 'Column options needs to be a Hash' unless column_options.is_a? Hash
66
+
67
+ column_options.assert_valid_keys(*COLUMN_OPTIONS)
68
+ table_name, column_name = column_key.to_s.split '.'
69
+ raise Error, 'Column key needs to have one dot table.column' if table_name.present? && column_name.nil?
70
+
71
+ if table_name.blank?
72
+ column_name = 'actions' # some default name for a title
73
+ else
74
+ table_class = table_name.singularize.camelcase.constantize
75
+ column_type_in_db = _determine_db_type_for_column(table_class, column_name)
76
+ end
77
+ arr << {
78
+ column_key: column_key.to_sym,
79
+ column_options: column_options,
80
+ table_class: table_class,
81
+ column_name: column_name,
82
+ column_type_in_db: column_type_in_db,
83
+ # the following are used for RenderHtml
84
+ title: column_options[TITLE_OPTION] || column_name.humanize,
85
+ html_options: html_options(column_options, column_type_in_db),
86
+ }
87
+ end
88
+ end
89
+
90
+ def _set_global_search_cols(global_search_cols)
91
+ @global_search_cols = global_search_cols.each_with_object([]) do |column_key, arr|
92
+ table_name, column_name = column_key.to_s.split '.'
93
+ table_class = table_name.singularize.camelcase.constantize
94
+ column_type_in_db = _determine_db_type_for_column(table_class, column_name)
95
+ arr << {
96
+ column_key: column_key.to_sym,
97
+ column_options: {},
98
+ table_class: table_class,
99
+ column_name: column_name,
100
+ column_type_in_db: column_type_in_db,
101
+ }
102
+ end
103
+ end
104
+
105
+ # This is helper
106
+ def _determine_string_type_cast # :nodoc:
107
+ raise NotImplementedError unless defined?(::ActiveRecord::Base)
108
+
109
+ DB_ADAPTER_STRING_TYPE_CAST[::ActiveRecord::Base.connection_config[:adapter].to_sym]
110
+ end
111
+
112
+ # @return
113
+ # :string, :integer, :date, :datetime
114
+ def _determine_db_type_for_column(table_class, column_name)
115
+ raise NotImplementedError unless defined?(::ActiveRecord::Base)
116
+
117
+ ar_column = table_class.columns_hash[column_name]
118
+ raise Error, "Can't find column #{column_name} in #{table_class.name}" unless ar_column
119
+
120
+ ar_column.type
121
+ end
122
+
123
+ def searchable
124
+ @data.reject do |column_key_option|
125
+ column_key_option[:column_options][SEARCH_OPTION] == false
126
+ end
127
+ end
128
+
129
+ def searchable_and_global_search
130
+ searchable + @global_search_cols
131
+ end
132
+
133
+ def [](index)
134
+ raise Error, "You asked for column index=#{index} but there is only #{@data.size} columns" if index >= @data.size
135
+
136
+ @data[index]
137
+ end
138
+
139
+ def each(&block)
140
+ @data.each(&block)
141
+ end
142
+
143
+ def size
144
+ @data.size
145
+ end
146
+
147
+ def index_by_column_key(column_key)
148
+ i = @data.find_index do |column_key_option|
149
+ column_key_option[:column_key] == column_key.to_sym
150
+ end
151
+ raise Error, "Can't find index for #{column_key} in #{@data.map { |d| d[:column_key] }.join(', ')}" if i.nil?
152
+
153
+ i
154
+ end
155
+
156
+ def html_options(column_options, column_type_in_db)
157
+ res = {}
158
+ res['data-searchable'] = false if column_options[SEARCH_OPTION] == false
159
+ res['data-orderable'] = false if column_options[ORDER_OPTION] == false
160
+ res['data-datatable-range'] = true if %i[date datetime].include? column_type_in_db
161
+ res
162
+ end
163
+ end
164
+ # rubocop:enable ClassLength
165
+ end