dtb 1.0.0.rc1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/gem-push.yml +31 -0
- data/.github/workflows/main.yml +20 -0
- data/.gitignore +8 -0
- data/.rubocop.yml +13 -0
- data/.standard.yml +1 -0
- data/.yardopts +1 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +16 -0
- data/Gemfile.lock +169 -0
- data/README.md +46 -0
- data/Rakefile +17 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/dtb.gemspec +37 -0
- data/lib/dtb/builds_data_table.rb +35 -0
- data/lib/dtb/column.rb +92 -0
- data/lib/dtb/data_table.rb +143 -0
- data/lib/dtb/empty_state.rb +76 -0
- data/lib/dtb/errors.rb +77 -0
- data/lib/dtb/filter.rb +229 -0
- data/lib/dtb/filter_set.rb +86 -0
- data/lib/dtb/has_columns.rb +81 -0
- data/lib/dtb/has_default_implementation.rb +77 -0
- data/lib/dtb/has_empty_state.rb +56 -0
- data/lib/dtb/has_filters.rb +149 -0
- data/lib/dtb/has_i18n.rb +73 -0
- data/lib/dtb/has_options.rb +89 -0
- data/lib/dtb/has_url.rb +64 -0
- data/lib/dtb/options_map.rb +149 -0
- data/lib/dtb/query.rb +150 -0
- data/lib/dtb/query_builder.rb +197 -0
- data/lib/dtb/query_builder_set.rb +110 -0
- data/lib/dtb/renderable.rb +171 -0
- data/lib/dtb/version.rb +6 -0
- data/lib/dtb.rb +8 -0
- metadata +170 -0
@@ -0,0 +1,143 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/core_ext/module/delegation"
|
4
|
+
require_relative "filter_set"
|
5
|
+
require_relative "query_builder_set"
|
6
|
+
require_relative "empty_state"
|
7
|
+
|
8
|
+
module DTB
|
9
|
+
# Data Tables act as presenter objects for the data returned from a {Query}.
|
10
|
+
# Queries pass data to this object, which is then passed to the view layer,
|
11
|
+
# providing methods to access the different components that should be rendered
|
12
|
+
# on the page.
|
13
|
+
#
|
14
|
+
# DataTables provide also a method (.build) to run the Query and turn it into
|
15
|
+
# a DataTable in one pass, since most likely that's what you will need on most
|
16
|
+
# endpoints that use these objects.
|
17
|
+
#
|
18
|
+
# @example build a data table in the controller
|
19
|
+
# def index
|
20
|
+
# @data_table = DTB::DataTable.build SomeQuery, params
|
21
|
+
# end
|
22
|
+
#
|
23
|
+
# @example render a data table on the view
|
24
|
+
# <%= render partial: @data_table.filters, as: :filters %>
|
25
|
+
#
|
26
|
+
# <% if @data_table.any? %>
|
27
|
+
# <table>
|
28
|
+
# <thead>
|
29
|
+
# <%= @data_table.columns.renderable.each do |column| %>
|
30
|
+
# <th><%= column.header %>
|
31
|
+
# <% end %>
|
32
|
+
# </thead>
|
33
|
+
# <tbody>
|
34
|
+
# <%= render partial: @data_table.rows %>
|
35
|
+
# </tbody>
|
36
|
+
# </table>
|
37
|
+
# <% else %>
|
38
|
+
# <%= render partial: @data_table.empty_state,
|
39
|
+
# as: :empty_state,
|
40
|
+
# locals: { data_table: @data_table } %>
|
41
|
+
# <% end %>
|
42
|
+
class DataTable
|
43
|
+
# @overload build(query_class, params = {}, options = {})
|
44
|
+
# @param query_class [Class<Query>] a Query class to run and turn into a
|
45
|
+
# data table.
|
46
|
+
# @param params [Hash] Any user-supplied params (such as filters to apply)
|
47
|
+
# @param options [Hash] Any options to customize this query.
|
48
|
+
# @raise (see HasOptions#initialize)
|
49
|
+
# @return [Datatable] the data table with the results of running the query.
|
50
|
+
#
|
51
|
+
# @overload build(query)
|
52
|
+
# @param query [Query] an instance of a Query which may or may not have
|
53
|
+
# been run yet.
|
54
|
+
# @return [Datatable] the data table with the results of running the query.
|
55
|
+
#
|
56
|
+
# @overload build(object, ...)
|
57
|
+
# @param object [#to_data_table] an object that implements +#to_data_table+.
|
58
|
+
# @param ... [Array<Object>] any parameters that should be forwarded to
|
59
|
+
# the +object+'s +#to_data_table+ method.
|
60
|
+
# @return [Datatable] the data table with the results of running the query.
|
61
|
+
# @see BuildsDataTable
|
62
|
+
def self.build(query, ...)
|
63
|
+
new(**query.to_data_table(...))
|
64
|
+
end
|
65
|
+
|
66
|
+
# @!method any?
|
67
|
+
# @return [Boolean] whether there are any rows to render.
|
68
|
+
# @!method empty?
|
69
|
+
# @return [Boolean] whether there are no rows to render.
|
70
|
+
# @!method each
|
71
|
+
# @yield each row of the query results
|
72
|
+
delegate :any?, :empty?, :each, to: :rows
|
73
|
+
|
74
|
+
# @return [Enumerable] the list of objects to render as rows of the table.
|
75
|
+
attr_reader :rows
|
76
|
+
|
77
|
+
# @return [QueryBuilderSet] the list of columns used for this query.
|
78
|
+
attr_reader :columns
|
79
|
+
|
80
|
+
# @return [FilterSet] the list of filters used for this query.
|
81
|
+
attr_reader :filters
|
82
|
+
|
83
|
+
# @return [Hash] the options used to configure the query.
|
84
|
+
attr_reader :options
|
85
|
+
|
86
|
+
# @return [EmptyState] the {EmptyState} object to use if there are no rows
|
87
|
+
# to render.
|
88
|
+
attr_reader :empty_state
|
89
|
+
|
90
|
+
# @param rows [Enumerable] a list of objects to generate the rows of the table.
|
91
|
+
# @param columns [QueryBuilderSet] the list of columns used for this query.
|
92
|
+
# Defaults to an empty set.
|
93
|
+
# @param filters [FilterSet] the list of filters used for this query.
|
94
|
+
# Defaults to an empty set.
|
95
|
+
# @param empty_state [EmptyState] the object to get information from if
|
96
|
+
# there are no rows. Defaults to an unconfigured {EmptyState}.
|
97
|
+
# @param options [Hash] the options used to configure the query.
|
98
|
+
# @param renderable [Renderable, nil] the source of rendering options for this
|
99
|
+
# data table. Normally, a Query object, or the same object that was used
|
100
|
+
# to generate the {#rows}. If +nil+, calling {#renderer} will return +nil+.
|
101
|
+
def initialize(
|
102
|
+
rows:,
|
103
|
+
columns: NO_COLUMNS,
|
104
|
+
filters: NO_FILTERS,
|
105
|
+
empty_state: DEFAULT_EMPTY_STATE,
|
106
|
+
options: {},
|
107
|
+
renderable: nil
|
108
|
+
)
|
109
|
+
@rows = rows
|
110
|
+
@columns = columns
|
111
|
+
@filters = filters
|
112
|
+
@empty_state = empty_state
|
113
|
+
@options = options
|
114
|
+
@renderable = renderable
|
115
|
+
end
|
116
|
+
|
117
|
+
# @return [Boolean] whether any of the filters was applied to get the
|
118
|
+
# current results.
|
119
|
+
def filtered?
|
120
|
+
@filtered ||= filters.applied.any?
|
121
|
+
end
|
122
|
+
|
123
|
+
# (see Renderable#renderer)
|
124
|
+
def renderer(**opts)
|
125
|
+
opts = opts.merge(rendering_options)
|
126
|
+
@renderable&.renderer(**opts)
|
127
|
+
end
|
128
|
+
|
129
|
+
# (see Renderable#rendering_options)
|
130
|
+
def rendering_options
|
131
|
+
{data_table: self}
|
132
|
+
end
|
133
|
+
|
134
|
+
NO_COLUMNS = QueryBuilderSet.new
|
135
|
+
private_constant :NO_COLUMNS
|
136
|
+
|
137
|
+
NO_FILTERS = FilterSet.new
|
138
|
+
private_constant :NO_FILTERS
|
139
|
+
|
140
|
+
DEFAULT_EMPTY_STATE = EmptyState.new
|
141
|
+
private_constant :DEFAULT_EMPTY_STATE
|
142
|
+
end
|
143
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "has_options"
|
4
|
+
require_relative "has_i18n"
|
5
|
+
require_relative "renderable"
|
6
|
+
|
7
|
+
module DTB
|
8
|
+
# The Empty State encapsulates the data you might want to render when a query
|
9
|
+
# returns no results. For each query, this will derive from your i18n sources
|
10
|
+
# three things:
|
11
|
+
#
|
12
|
+
# * a {title} to show in the page (for example "No results!")
|
13
|
+
# * an {explanation} to indicate why the query returned no results (this is
|
14
|
+
# useful to present on a "blank slate" scenario, when the underlying
|
15
|
+
# storage doesn't have data yet.)
|
16
|
+
# * a call to action to {update_filters} in the case the user has applied
|
17
|
+
# filters and that resulted in the results being empty.
|
18
|
+
#
|
19
|
+
# The names of those methods is purely informational and doesn't carry any
|
20
|
+
# behavior with it. You are welcome to use these strings in any way you want,
|
21
|
+
# or not at all.
|
22
|
+
#
|
23
|
+
# == Rendering the Empty State
|
24
|
+
#
|
25
|
+
# Each empty state is a {Renderable} object, and as such, you can define how
|
26
|
+
# to render it via the {#render_with} option, and by then calling the
|
27
|
+
# {#renderer} method.
|
28
|
+
#
|
29
|
+
# @see HasEmptyState
|
30
|
+
class EmptyState
|
31
|
+
include HasOptions
|
32
|
+
include HasI18n
|
33
|
+
include Renderable
|
34
|
+
|
35
|
+
# @!group Options
|
36
|
+
|
37
|
+
# @!attribute [rw] context
|
38
|
+
# @return [Object, nil] the Object to use as context to evaluate the i18n
|
39
|
+
# sources.
|
40
|
+
# ` @see HasI18n#i18n_lookup
|
41
|
+
option :context
|
42
|
+
|
43
|
+
# @!endgroup
|
44
|
+
|
45
|
+
# Determine the "title" of the default empty state container that is
|
46
|
+
# rendered. This looks up a translation under the +empty_states+ namespace,
|
47
|
+
# optionally using this empty state's #context.
|
48
|
+
#
|
49
|
+
# @return [String]
|
50
|
+
# @see #i18n_lookup
|
51
|
+
def title
|
52
|
+
i18n_lookup(:title, :empty_states, context: options[:context])
|
53
|
+
end
|
54
|
+
|
55
|
+
# Determine the "explanation" to render when a query has no results to show.
|
56
|
+
# This looks up a translation under the +empty_states+ namespace, optionally
|
57
|
+
# using this empty state's #context.
|
58
|
+
#
|
59
|
+
# @return [String]
|
60
|
+
# @see #i18n_lookup
|
61
|
+
def explanation
|
62
|
+
i18n_lookup(:explanation, :empty_states, context: options[:context], default: "")
|
63
|
+
end
|
64
|
+
|
65
|
+
# Determine the explanation to render when a query has no results to show
|
66
|
+
# and filters are applied. This should be a call to action to let users know
|
67
|
+
# they should relax the filters. This looks up a translation under the
|
68
|
+
# +empty_states+ namespace, optionally using this empty state's #context.
|
69
|
+
#
|
70
|
+
# @return [String]
|
71
|
+
# @see #i18n_lookup
|
72
|
+
def update_filters
|
73
|
+
i18n_lookup(:update_filters, :empty_states, context: options[:context], default: "")
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
data/lib/dtb/errors.rb
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DTB
|
4
|
+
# Mixin for other errors raised from this library, so that you can always
|
5
|
+
# rescue +DTB::Error+.
|
6
|
+
module Error; end
|
7
|
+
|
8
|
+
# Raised when initializing an object that supports options with an option that
|
9
|
+
# hasn't been defined.
|
10
|
+
#
|
11
|
+
# @see HasOptions
|
12
|
+
# @see OptionsMap
|
13
|
+
class UnknownOptionsError < ArgumentError
|
14
|
+
include DTB::Error
|
15
|
+
|
16
|
+
# @return [OptionsMap] The invalid options hash.
|
17
|
+
attr_reader :options
|
18
|
+
|
19
|
+
# @return [Set] The options that are defined for this hash.
|
20
|
+
attr_reader :valid_options
|
21
|
+
|
22
|
+
# @return [Set] The options that are present in the Hash that aren't defined.
|
23
|
+
attr_reader :unknown_options
|
24
|
+
|
25
|
+
# @api private
|
26
|
+
def initialize(options)
|
27
|
+
unknown = options.keys.to_set - options.valid_keys
|
28
|
+
super(format(MESSAGE, unknown.to_a, options.valid_keys.to_a))
|
29
|
+
@options = options
|
30
|
+
@valid_options = options.valid_keys
|
31
|
+
@unknown_options = unknown
|
32
|
+
end
|
33
|
+
|
34
|
+
MESSAGE = "Unknown options: %p. Valid options are: %p"
|
35
|
+
private_constant :MESSAGE
|
36
|
+
end
|
37
|
+
|
38
|
+
# Raised when initializing an object that supports options without passing all
|
39
|
+
# the required options.
|
40
|
+
#
|
41
|
+
# @see HasOptions
|
42
|
+
# @see OptionsMap
|
43
|
+
class MissingOptionsError < ArgumentError
|
44
|
+
include DTB::Error
|
45
|
+
|
46
|
+
# @return [OptionsMap] The invalid options hash.
|
47
|
+
attr_reader :options
|
48
|
+
|
49
|
+
# @return [Set] The options that are required for this Hash.
|
50
|
+
attr_reader :required_options
|
51
|
+
|
52
|
+
# @return [Set] The required options that are missing from this Hash.
|
53
|
+
attr_reader :missing_options
|
54
|
+
|
55
|
+
# @api private
|
56
|
+
def initialize(options)
|
57
|
+
missing = options.required_keys - options.keys
|
58
|
+
super(format(MESSAGE, missing.to_a, options))
|
59
|
+
@options = options
|
60
|
+
@required_options = options.required_keys
|
61
|
+
@missing_options = missing
|
62
|
+
end
|
63
|
+
|
64
|
+
MESSAGE = "Missing required options: %p. Options given were: %p"
|
65
|
+
private_constant :MESSAGE
|
66
|
+
end
|
67
|
+
|
68
|
+
# rubocop:disable Lint/InheritException
|
69
|
+
|
70
|
+
# Extends +NotImplementedError+ to be catchable as a library error via
|
71
|
+
# +rescue DTB::Error+. Normally you wouldn't rescue this in code, though,
|
72
|
+
# but rather use it to get failing tests / exceptions while developing.
|
73
|
+
class NotImplementedError < ::NotImplementedError
|
74
|
+
include DTB::Error
|
75
|
+
end
|
76
|
+
# rubocop:enable Lint/InheritException
|
77
|
+
end
|
data/lib/dtb/filter.rb
ADDED
@@ -0,0 +1,229 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/core_ext/object/blank"
|
4
|
+
require "active_support/core_ext/string/inflections"
|
5
|
+
require_relative "query_builder"
|
6
|
+
require_relative "has_options"
|
7
|
+
require_relative "renderable"
|
8
|
+
|
9
|
+
module DTB
|
10
|
+
# Filters allow setting conditions on a query, which are optionally applied
|
11
|
+
# depending on user input. You are meant to subclass this and define your own
|
12
|
+
# specialized filters based on your application's needs.
|
13
|
+
#
|
14
|
+
# == Query Builders
|
15
|
+
#
|
16
|
+
# The filter, as other query builders, depends on a Proc that accepts both a
|
17
|
+
# +scope+ and the user provided +value+ for this filter. As other
|
18
|
+
# {QueryBuilder Query Builders}, filters respond to {#call}, which evaluates
|
19
|
+
# the proc only if the value is present.
|
20
|
+
#
|
21
|
+
# with_value = Filter.new(:name, value: "Jane Doe") do |scope, value|
|
22
|
+
# scope.where(name: value)
|
23
|
+
# end
|
24
|
+
# without_value = Filter.new(:name, value: nil) do |scope, value|
|
25
|
+
# scope.where(name: value)
|
26
|
+
# end
|
27
|
+
#
|
28
|
+
# scope = User.all
|
29
|
+
# without_value.call(scope) #=> User.all
|
30
|
+
# with_value.call(scope) #=> User.all.where(name: "Jane Doe")
|
31
|
+
#
|
32
|
+
# == Value Sanitization
|
33
|
+
#
|
34
|
+
# By default, the value is passed as-is to the Proc. You might want to format
|
35
|
+
# it or sanitize it in any other way:
|
36
|
+
#
|
37
|
+
# filter = Filter.new(
|
38
|
+
# :name,
|
39
|
+
# value: " string ",
|
40
|
+
# sanitize: ->(value) { value&.strip&.upcase }
|
41
|
+
# ) { |scope, value| scope.where(name => value) }
|
42
|
+
#
|
43
|
+
# filter.call(User.all) #=> User.all.where(name: "STRING")
|
44
|
+
#
|
45
|
+
# *NOTE*: Keep in mind that the value received by +sanitize+ might be +nil+.
|
46
|
+
#
|
47
|
+
# == Default Values
|
48
|
+
#
|
49
|
+
# Usually you want filters to run only when the user supplies a value, but
|
50
|
+
# sometimes you want the query to always be filtered in some way, with the
|
51
|
+
# user having control on the specific value of the filter.
|
52
|
+
#
|
53
|
+
# For example, a query might always return a window of time, but the user
|
54
|
+
# could choose whether that's "last week", "last month", or "last year", and
|
55
|
+
# by default you want this to be "last week".
|
56
|
+
#
|
57
|
+
# # if the user sends 30 (i.e. last 30 days), we will use that value.
|
58
|
+
# filter = Filter.new(:name, value: 30, default: 7) do |scope, value|
|
59
|
+
# scope.where("created_at > ?", value.days.ago)
|
60
|
+
# end
|
61
|
+
#
|
62
|
+
# # if the user doesn't set this filter, we will use 7 as the default value.
|
63
|
+
# filter = Filter.new(:name, value: nil, default: 7) do |scope, value|
|
64
|
+
# scope.where("created_at > ?", value.days.ago)
|
65
|
+
# end
|
66
|
+
#
|
67
|
+
# If the given default is a Proc/lambda, it will be evaluated, and the return
|
68
|
+
# value of the Proc will be used as the default:
|
69
|
+
#
|
70
|
+
# # the default value will be the current user's currency
|
71
|
+
# filter = Filter.new(:currency, default: -> { Current.user.currency })
|
72
|
+
#
|
73
|
+
# == Rendering filters
|
74
|
+
#
|
75
|
+
# To render a filter in the view, you can call its {#renderer} method, and
|
76
|
+
# pass the output to the +render+ helper:
|
77
|
+
#
|
78
|
+
# <%= render filter.renderer %>
|
79
|
+
#
|
80
|
+
# To configure how that renderer behaves, Filters accept a +rendes_with+
|
81
|
+
# option that defines how they can be rendered. This lets you render different
|
82
|
+
# widgets for each filter, where you can customize the form control used (i.e.
|
83
|
+
# a text field vs a number field vs a select box).
|
84
|
+
#
|
85
|
+
# By default, filters are rendered using a partial template named after the
|
86
|
+
# filter's class. For example, a +SelectFilter+ would be rendered in the
|
87
|
+
# +"filters/select_filter"+ partial. The partial receives a local named
|
88
|
+
# +filter+ with the filter object.
|
89
|
+
#
|
90
|
+
# Alternatively, you can pass a callable to +render_with+ that returns valid
|
91
|
+
# attributes for ActionView's +render+ method. This could be a Hash (i.e. to
|
92
|
+
# +render+ a custom partial with extra options) or it could be an object that
|
93
|
+
# responds to +render_in+.
|
94
|
+
#
|
95
|
+
# Finally, you can just pass a Class. If you do, DTB will insantiate it with a
|
96
|
+
# +filter+ keyword, and return the instance. This is useful when using
|
97
|
+
# component libraries such as ViewComponent or Phlex.
|
98
|
+
#
|
99
|
+
# class SelectFilter < DTB::Filter
|
100
|
+
# option :render_with, default: SelectFilterComponent
|
101
|
+
# end
|
102
|
+
#
|
103
|
+
# == Passing extra options to the renderer
|
104
|
+
#
|
105
|
+
# Whatever options you pass to the {#renderer} method, they will be
|
106
|
+
# forwarded to the configured renderer via {#render_with}. For example,
|
107
|
+
# given:
|
108
|
+
#
|
109
|
+
# class SelectFilter < DTB::Filter
|
110
|
+
# option :render_with, default: SelectFilterComponent
|
111
|
+
# end
|
112
|
+
#
|
113
|
+
# The following two statements are equivalent
|
114
|
+
#
|
115
|
+
# <%= render filter.renderer(class: "custom-class") %>
|
116
|
+
# <%= render SelectFilterComponent.new(filter: filter, class: "custom-class") %>
|
117
|
+
#
|
118
|
+
# == Overriding the options passed to the renderer
|
119
|
+
#
|
120
|
+
# The default options passed to the rendered are the return value of the
|
121
|
+
# {#rendering_options} method. You can always override it to customize how the
|
122
|
+
# object is passed to the renderer, or to pass other options that you always
|
123
|
+
# need to include (rather than passing them on every {#renderer}) invocation.
|
124
|
+
#
|
125
|
+
# @example Overriding the rendering options
|
126
|
+
# class AutocompleteFilter < DTB::Filter
|
127
|
+
# option :render_with, default: AutocompleteFilterComponent
|
128
|
+
#
|
129
|
+
# def rendering_options
|
130
|
+
# # super here returns `{filter: self}`
|
131
|
+
# {url: autocomplete_url}.update(super)
|
132
|
+
# end
|
133
|
+
# end
|
134
|
+
#
|
135
|
+
# @see HasFilters
|
136
|
+
class Filter < QueryBuilder
|
137
|
+
include HasOptions
|
138
|
+
include Renderable
|
139
|
+
|
140
|
+
# @!group Options
|
141
|
+
|
142
|
+
# @!attribute [rw] value
|
143
|
+
# @return [Object, nil] the user-supplied value for this filter.
|
144
|
+
option :value, required: true
|
145
|
+
|
146
|
+
# @!attribute [rw] sanitize
|
147
|
+
# @return [Proc] a Proc to sanitize the user input. Defaults to a Proc
|
148
|
+
# that returns the input value.
|
149
|
+
option :sanitize, default: IDENT, required: true
|
150
|
+
|
151
|
+
# @!attribute [rw] default
|
152
|
+
# @return [Object, Proc nil] a default value to use if the user supplies a
|
153
|
+
# blank value. If given a Proc, it will be evaluated and its return
|
154
|
+
# value used as the default.
|
155
|
+
option :default
|
156
|
+
|
157
|
+
# @!attribute [rw] render_with
|
158
|
+
# @see Renderable#render_with
|
159
|
+
option :render_with,
|
160
|
+
default: ->(filter:, **opts) {
|
161
|
+
{partial: "filters/#{filter.class.name.underscore}", locals: {filter: filter, **opts}}
|
162
|
+
},
|
163
|
+
required: true
|
164
|
+
|
165
|
+
# @!endgroup
|
166
|
+
|
167
|
+
# Applies the Proc if the value given by the user is present, and the filter
|
168
|
+
# isn't turned off in another way (e.g. via +if+/+unless+) settings.
|
169
|
+
#
|
170
|
+
# @param scope (see QueryBuilder#call)
|
171
|
+
# @return (see QueryBuilder#call)
|
172
|
+
# @raise (see QueryBuilder#call)
|
173
|
+
def call(scope)
|
174
|
+
super(scope, value).tap do
|
175
|
+
# We only want to consider this filter applied if it has a _custom_
|
176
|
+
# value set, not if it's just using the default value.
|
177
|
+
@applied = false if @applied && sanitized_value.blank?
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
# @return [Object, nil] the value used to decide if the filter should be
|
182
|
+
# applied. This can be a user supplied value (after sanitizing), or the
|
183
|
+
# default value, if set.
|
184
|
+
def value
|
185
|
+
sanitized_value.presence || default_value
|
186
|
+
end
|
187
|
+
|
188
|
+
# Determine the content of the +<label>+ tag that should be shown when
|
189
|
+
# rendering this filter. This will look up the translation under the
|
190
|
+
# +filters+ namespace.
|
191
|
+
#
|
192
|
+
# @return [String]
|
193
|
+
# @see QueryBuilder#i18n_lookup
|
194
|
+
def label
|
195
|
+
i18n_lookup(:filters)
|
196
|
+
end
|
197
|
+
|
198
|
+
# Determine the content of the +placeholder+ attribute that should be used
|
199
|
+
# when rendering this filter. This will look up the translation under the
|
200
|
+
# +placeholders+ namespace.
|
201
|
+
#
|
202
|
+
# @return [String]
|
203
|
+
# @see QueryBuilder#i18n_lookup
|
204
|
+
def placeholder
|
205
|
+
i18n_lookup(:placeholders, default: "")
|
206
|
+
end
|
207
|
+
|
208
|
+
# @api private
|
209
|
+
def evaluate?
|
210
|
+
value.present? && super
|
211
|
+
end
|
212
|
+
|
213
|
+
private def rendering_options
|
214
|
+
{filter: self}
|
215
|
+
end
|
216
|
+
|
217
|
+
private def default_value
|
218
|
+
if options[:default].respond_to?(:call)
|
219
|
+
evaluate(with: options[:default])
|
220
|
+
else
|
221
|
+
options[:default]
|
222
|
+
end
|
223
|
+
end
|
224
|
+
|
225
|
+
private def sanitized_value
|
226
|
+
options[:sanitize].call(options[:value])
|
227
|
+
end
|
228
|
+
end
|
229
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "query_builder_set"
|
4
|
+
require_relative "renderable"
|
5
|
+
|
6
|
+
module DTB
|
7
|
+
# Filter sets extend {QueryBuilderSet QueryBuilder sets} by adding a few
|
8
|
+
# options to help render the filters form.
|
9
|
+
#
|
10
|
+
# == Rendering the filters form
|
11
|
+
#
|
12
|
+
# Start by defining a partial for your form. Default location is
|
13
|
+
# +filters/filters+, so +app/views/filters/_filters.html.erb+ is a good place
|
14
|
+
# to start, with at least these components:
|
15
|
+
#
|
16
|
+
# <%= form_with method: :get, scope: filters.namespace, url: filters.submit_url do |form| %>
|
17
|
+
# <% filters.each do |filter| %>
|
18
|
+
# <%= render partial: filter, locals: { form: form } %>
|
19
|
+
# <% end %>
|
20
|
+
#
|
21
|
+
# <%= form.submit %>
|
22
|
+
#
|
23
|
+
# <% if filters.reset_url.present? %>
|
24
|
+
# <%= form.link_to t(".reset"), filters.reset_url, class: "btn" %>
|
25
|
+
# <% end %>
|
26
|
+
# <% end %>
|
27
|
+
#
|
28
|
+
class FilterSet < QueryBuilderSet
|
29
|
+
include Renderable
|
30
|
+
|
31
|
+
# @!group Options
|
32
|
+
|
33
|
+
# @!attribute [rw] param
|
34
|
+
# This is the name of the query string parameter used to group filters in
|
35
|
+
# the form. {HasFilters} uses this to determine which sub-Hash of the
|
36
|
+
# parameters object to use as the values for filters.
|
37
|
+
# @return [Symbol] the name of the top-level param name. Defaults to
|
38
|
+
# +:filters+.
|
39
|
+
# @see #namespace
|
40
|
+
option :param, default: :filters, required: true
|
41
|
+
|
42
|
+
# @!attribute [rw] renders_with (see Renderable#renders_with)
|
43
|
+
option :render_with, default: "filters/filters", required: true
|
44
|
+
|
45
|
+
# @!attribute [rw] submit_url
|
46
|
+
# @return [String] the URL to submit the filters form to.
|
47
|
+
option :submit_url
|
48
|
+
|
49
|
+
# @!attribute [rw] reset_url
|
50
|
+
# @return [String, nil] the URL to reset the filters form to.
|
51
|
+
option :reset_url
|
52
|
+
|
53
|
+
# @!endgroup
|
54
|
+
|
55
|
+
# The keyword to use as the namespace for all form fields when rendering the
|
56
|
+
# filters form.
|
57
|
+
#
|
58
|
+
# @example Rendering the filters form with +form_with+
|
59
|
+
# <%= form_with scope: filters.namespace, url: filters.submit_url do |form| %>
|
60
|
+
# ...
|
61
|
+
# <% end %>
|
62
|
+
#
|
63
|
+
# @example Rendering the filters form with +form_for+
|
64
|
+
# <%= form_for filters.namespace, url: filters.submit_url do |form| %>
|
65
|
+
# ...
|
66
|
+
# <% end %>
|
67
|
+
#
|
68
|
+
# @return [Symbol]
|
69
|
+
# @see #param
|
70
|
+
def namespace
|
71
|
+
options[:param]
|
72
|
+
end
|
73
|
+
|
74
|
+
def submit_url
|
75
|
+
options[:submit_url]
|
76
|
+
end
|
77
|
+
|
78
|
+
def reset_url
|
79
|
+
options[:reset_url]
|
80
|
+
end
|
81
|
+
|
82
|
+
private def rendering_options
|
83
|
+
{filters: self}
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/concern"
|
4
|
+
require_relative "column"
|
5
|
+
require_relative "has_options"
|
6
|
+
require_relative "query_builder_set"
|
7
|
+
|
8
|
+
module DTB
|
9
|
+
# This mixin provides {Query Queries} with a set of column objects that can be
|
10
|
+
# used to modify the query and to render in the view. Including this module
|
11
|
+
# gives you access to the {.column} class method, which you can use to define
|
12
|
+
# columns in your query.
|
13
|
+
#
|
14
|
+
# @example (see .column)
|
15
|
+
module HasColumns
|
16
|
+
extend ActiveSupport::Concern
|
17
|
+
include HasDefaultImplementation
|
18
|
+
include BuildsDataTable
|
19
|
+
include HasOptions
|
20
|
+
|
21
|
+
included do
|
22
|
+
# @!group Options
|
23
|
+
|
24
|
+
# @!attribute [rw] default_column_type
|
25
|
+
# The default class for columns added. Defaults to {Column}.
|
26
|
+
option :default_column_type, default: Column
|
27
|
+
|
28
|
+
# @!endgroup
|
29
|
+
end
|
30
|
+
|
31
|
+
class_methods do
|
32
|
+
# Defines a new Column that will be added to this Query.
|
33
|
+
#
|
34
|
+
# @example Adding a column that references an associated resource
|
35
|
+
# column :author_id,
|
36
|
+
# ->(scope) { scope.select(:author_id).includes(:author) }
|
37
|
+
#
|
38
|
+
# @example Adding a column that doesn't modify the database query but is rendered
|
39
|
+
# column :actions, database: false
|
40
|
+
#
|
41
|
+
# @param name [Symbol]
|
42
|
+
# @param query [Proc] The {QueryBuilder} proc.
|
43
|
+
# @param type [Class<Column>] The type of column to use. Defaults to
|
44
|
+
# whatever is set as the {default_column_type}.
|
45
|
+
# @param opts [Hash] Any other options required by the +type+.
|
46
|
+
# @return [void]
|
47
|
+
def column(name, query = ->(scope) { scope.select(name) }, type: options[:default_column_type], **opts)
|
48
|
+
column_definitions << {type: type, name: name, query: query, options: opts}
|
49
|
+
end
|
50
|
+
|
51
|
+
# @api private
|
52
|
+
# @return [Array<Hash>]
|
53
|
+
def column_definitions
|
54
|
+
@column_definitions ||= []
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# @return [QueryBuilderSet] the set of columns defined on this object.
|
59
|
+
def columns
|
60
|
+
return @columns if defined?(@columns)
|
61
|
+
|
62
|
+
columns = self.class.column_definitions.map do |dfn|
|
63
|
+
dfn[:type].new(dfn[:name], context: self, **dfn[:options], &dfn[:query])
|
64
|
+
end
|
65
|
+
|
66
|
+
@columns = QueryBuilderSet.new(columns)
|
67
|
+
end
|
68
|
+
|
69
|
+
# Applies all defined columns to the query being built.
|
70
|
+
#
|
71
|
+
# @return (see HasDefaultImplementation#run)
|
72
|
+
def run
|
73
|
+
columns.call(super)
|
74
|
+
end
|
75
|
+
|
76
|
+
# (see BuildsDataTable#to_data_table)
|
77
|
+
def to_data_table
|
78
|
+
super.merge(columns: columns)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|