dtb 1.0.0.rc1
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.
- 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
|