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
data/lib/dtb/query.rb
ADDED
@@ -0,0 +1,150 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_model/naming"
|
4
|
+
require "active_model/translation"
|
5
|
+
require_relative "builds_data_table"
|
6
|
+
require_relative "has_default_implementation"
|
7
|
+
require_relative "has_options"
|
8
|
+
require_relative "has_columns"
|
9
|
+
require_relative "has_filters"
|
10
|
+
require_relative "has_empty_state"
|
11
|
+
require_relative "renderable"
|
12
|
+
|
13
|
+
module DTB
|
14
|
+
# Queries are the base classes that allow you to model both what data is
|
15
|
+
# fetched from the database and how it is rendered to users.
|
16
|
+
#
|
17
|
+
# A Query is nothing more than a collection of filters, columns, and an
|
18
|
+
# initial scope from which to start querying. For example:
|
19
|
+
#
|
20
|
+
# class Blog::PostsQuery < DTB::Query
|
21
|
+
# column :title, ->(scope) { scope.select(:title, :id) }
|
22
|
+
# column :author, ->(scope) { scope.select(:author_id).includes(:author) }
|
23
|
+
# column :published, ->(scope) { scope.select(:published_at) }
|
24
|
+
# column :actions, database: false
|
25
|
+
#
|
26
|
+
# filter :title,
|
27
|
+
# ->(scope, value) { scope.where("title ILIKE ?", "%#{value}%") }
|
28
|
+
# filter :published,
|
29
|
+
# ->(scope, value) { scope.where(published: value) }
|
30
|
+
#
|
31
|
+
# default_scope { Post.all }
|
32
|
+
# end
|
33
|
+
#
|
34
|
+
# == Running a Query
|
35
|
+
#
|
36
|
+
# This query would start from +Post.all+, then "apply" all columns, by
|
37
|
+
# modifying the query to add each clause declared in the columns, and finally
|
38
|
+
# look at the input params given when running to decide which filters should
|
39
|
+
# be applied.
|
40
|
+
#
|
41
|
+
# For example, in the below example query, the params include the `title`
|
42
|
+
# filter, but not the `published` filter:
|
43
|
+
#
|
44
|
+
# Blog::PostsQuery.run(filters: {title: "test"}) #=> #<ActiveRecord::Relation ...>
|
45
|
+
#
|
46
|
+
# # Given those input parameters, that code is equivalent to this:
|
47
|
+
# Post.all
|
48
|
+
# .select(:title, :id)
|
49
|
+
# .select(:author_id).includes(:author)
|
50
|
+
# .select(:published_at)
|
51
|
+
# .where("title ILIKE ?", "%test%")
|
52
|
+
#
|
53
|
+
# == Scoping queries to only return authorized data
|
54
|
+
#
|
55
|
+
# Usually, your queries will be scoped to data visible by a user or account in
|
56
|
+
# your system. If you're using +ActiveSupport::CurrentAttributes+, you could
|
57
|
+
# set your initial scope with this:
|
58
|
+
#
|
59
|
+
# default_scope { Current.user.posts }
|
60
|
+
#
|
61
|
+
# But if you're not, you will need to have access to the +current_user+ or
|
62
|
+
# whatever you call it. For this, the recommended approach is to implement
|
63
|
+
# a base query object that provides access to this:
|
64
|
+
#
|
65
|
+
# class ApplicationQuery < DTB::Query
|
66
|
+
# attr_reader :current_user
|
67
|
+
#
|
68
|
+
# def initialize(current_user, *args)
|
69
|
+
# super(*args)
|
70
|
+
# @current_user = current_user
|
71
|
+
# end
|
72
|
+
# end
|
73
|
+
#
|
74
|
+
# class Blog::PostsQuery < ApplicationQuery
|
75
|
+
# default_scope { current_user.posts }
|
76
|
+
# end
|
77
|
+
#
|
78
|
+
# All the Procs (the +default_scope+ and the procs attached to columns and
|
79
|
+
# filters) are evaluated in the context of the Query class itself, so you have
|
80
|
+
# access to its instance methods and variables.
|
81
|
+
#
|
82
|
+
# == Rendering the Query as a Data Table
|
83
|
+
#
|
84
|
+
# DTB can easily turn the Query results into a {DataTable} object, which
|
85
|
+
# provides some basic structure so templates can render this into a table with
|
86
|
+
# all the data, next to a filters panel.
|
87
|
+
#
|
88
|
+
# You can easily turn a Query into a DataTable:
|
89
|
+
#
|
90
|
+
# DataTable.build(Blog::PostsQuery, {filters: {title: "test"}})
|
91
|
+
#
|
92
|
+
# Check out the {DataTable} documentation for more on how to build and
|
93
|
+
# customize data tables.
|
94
|
+
#
|
95
|
+
# @see HasColumns
|
96
|
+
# @see HasFilters
|
97
|
+
class Query
|
98
|
+
extend ActiveModel::Translation
|
99
|
+
include HasDefaultImplementation
|
100
|
+
include HasOptions
|
101
|
+
include BuildsDataTable
|
102
|
+
include HasColumns
|
103
|
+
include HasFilters
|
104
|
+
include HasUrl
|
105
|
+
include HasEmptyState
|
106
|
+
include Renderable
|
107
|
+
|
108
|
+
# Provide a base scope of +queries+ for translations. Unless overridde,
|
109
|
+
# translations within query objects will be found under
|
110
|
+
# +queries.{namespace}.{query_class}+.
|
111
|
+
#
|
112
|
+
# @see https://api.rubyonrails.org/classes/ActiveModel/Translation.html
|
113
|
+
def self.i18n_scope
|
114
|
+
:queries
|
115
|
+
end
|
116
|
+
|
117
|
+
# Run the query, returning the results.
|
118
|
+
#
|
119
|
+
# @param ... [Array<Object>] Any arguments given will be forwarded to
|
120
|
+
# #initialize
|
121
|
+
# @return (see #run)
|
122
|
+
def self.run(...)
|
123
|
+
new(...).run
|
124
|
+
end
|
125
|
+
|
126
|
+
# @!method initialize(params = {}, options = {})
|
127
|
+
# @param params [Hash] Any user-supplied params (such as filters to apply)
|
128
|
+
# @param options [Hash] Any options to customize this query.
|
129
|
+
# @raise (see HasOptions#initialize)
|
130
|
+
|
131
|
+
# @!method run
|
132
|
+
# Runs the query, starting from its default scope, if defined, and
|
133
|
+
# applying all columns and filters.
|
134
|
+
#
|
135
|
+
# @return the result of running the query.
|
136
|
+
# @raise {NotImplementedError} if no default scope is defined.
|
137
|
+
|
138
|
+
# @!method to_data_table
|
139
|
+
# @return [Hash<Symbol, Object>] a Hash of arguments suitable to pass to
|
140
|
+
# {DataTable#initialize}
|
141
|
+
|
142
|
+
# (see Renderable#rendering_options)
|
143
|
+
def rendering_options
|
144
|
+
# NOTE: Normally this is exposed through a DataTable, and we don't want to
|
145
|
+
# expose query internals when rendering. You can always override this if
|
146
|
+
# you do want the Query instance available to the Renderer, though.
|
147
|
+
{}
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
@@ -0,0 +1,197 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "has_i18n"
|
4
|
+
require_relative "has_options"
|
5
|
+
|
6
|
+
module DTB
|
7
|
+
# Query builders are the "atoms" of a Query. They specify a specific behavior
|
8
|
+
# scoped to a single part of the query. For example, a column or a filter.
|
9
|
+
# This class is not meant to be used directly, but instead extended with
|
10
|
+
# concrete behavior.
|
11
|
+
#
|
12
|
+
# The central part of a query builder is a Proc that will receive an object
|
13
|
+
# (e.g. an ActiveRecord::Relation) and is expected to return that object,
|
14
|
+
# modified in whatever way the builder is meant to work.
|
15
|
+
#
|
16
|
+
# Query builders have an optional "execution context" (usually an instance of
|
17
|
+
# the {Query} class) which is used to evaluate their Proc, giving it access to
|
18
|
+
# any state / methods in that object.
|
19
|
+
#
|
20
|
+
# The central interface to query builders is the {#call} method, which given a
|
21
|
+
# "scope", will decide if the query builder's Proc should be called, and
|
22
|
+
# either return the result of the Proc, or if it doesn't need to evaluate
|
23
|
+
# itself, will return the input "scope" as is.
|
24
|
+
#
|
25
|
+
# In order to decide whether it should be evaluated, query builders rely on
|
26
|
+
# the {#evaluate?} method and/or the {#render?} method. {#render?} decides if
|
27
|
+
# the atom being defined by this builder is something that should be displayed
|
28
|
+
# back to the user, and {#evaluate?} checks if the Proc should be evaluated or
|
29
|
+
# skipped.
|
30
|
+
#
|
31
|
+
# Normally, something that should not be rendered should not be evaluated, so
|
32
|
+
# the default behavior is that {#evaluate?} depends on {#render?}. However,
|
33
|
+
# you may change this in sub-classes. For a concrete example, if you are not
|
34
|
+
# going to display a column in the table to users, it makes no sense to add
|
35
|
+
# extra data to users.
|
36
|
+
#
|
37
|
+
# @abstract
|
38
|
+
# @see Column
|
39
|
+
# @see Filter
|
40
|
+
class QueryBuilder
|
41
|
+
include HasOptions
|
42
|
+
include HasI18n
|
43
|
+
|
44
|
+
# @!group Options
|
45
|
+
|
46
|
+
# @!attribute [rw] context
|
47
|
+
# @return [Object, nil] The Object in which the {QueryBuilder}'s proc is
|
48
|
+
# evaluated.
|
49
|
+
option :context, default: nil
|
50
|
+
|
51
|
+
# @!attribute [rw] if
|
52
|
+
# @return [Proc] A Proc that returns a Boolean. If it returns +false+ then
|
53
|
+
# {#call} will skip evaluating the {QueryBuilder}'s proc.
|
54
|
+
option :if, default: -> { true }
|
55
|
+
|
56
|
+
# @!attribute [rw] unless
|
57
|
+
# @return [Proc] A Proc that returns a Boolean. If it returns +true+ then
|
58
|
+
# {#call} will skip evaluating the {QueryBuilder}'s proc.
|
59
|
+
option :unless, default: -> { false }
|
60
|
+
|
61
|
+
# @!endgroup
|
62
|
+
|
63
|
+
IDENT = ->(value) { value }
|
64
|
+
private_constant :IDENT
|
65
|
+
|
66
|
+
# @return [Symbol] The name of this QueryBuilder.
|
67
|
+
attr_reader :name
|
68
|
+
|
69
|
+
# @param name [Symbol] The QueryBuilder's name.
|
70
|
+
# @param opts [Hash] Any options that need to be set. See also {HasOptions}.
|
71
|
+
# @yield [scope, ...] The given block will be used by {#call} to modify
|
72
|
+
# the given input scope
|
73
|
+
# @raise (see HasOptions#initialize)
|
74
|
+
def initialize(name, opts = {}, &query)
|
75
|
+
super(opts)
|
76
|
+
@name = name
|
77
|
+
@query = query
|
78
|
+
@applied = false
|
79
|
+
end
|
80
|
+
|
81
|
+
# Evaluates this QueryBuilder's Proc if necessary, returning either the
|
82
|
+
# input +scope+ or the output of the Proc.
|
83
|
+
#
|
84
|
+
# @param scope [Object] the "query" being built.
|
85
|
+
# @param ... [Array<Object>] Splat of any other params that are accepted by
|
86
|
+
# this QueryBuilder's Proc.
|
87
|
+
# @return [Object] the modified "query" or the input +scope+.
|
88
|
+
#
|
89
|
+
# @see #evaluate?
|
90
|
+
def call(scope, ...)
|
91
|
+
if evaluate?
|
92
|
+
@applied = true
|
93
|
+
evaluate(scope, ...)
|
94
|
+
else
|
95
|
+
scope
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# Evaluates a Proc in the context of this QueryBuilder's +context+, as given
|
100
|
+
# in the options.
|
101
|
+
#
|
102
|
+
# @param args [Array<Object>] Any arguments will be forwarded to the Proc.
|
103
|
+
# @param with [Proc] A Proc. Defaults to this QueryBuilder's main Proc.
|
104
|
+
# @api private
|
105
|
+
def evaluate(*args, with: @query, **opts)
|
106
|
+
options[:context].instance_exec(*args, **opts, &with)
|
107
|
+
end
|
108
|
+
|
109
|
+
# @return [Boolean] Whether this QueryBuilder's Proc has been used or not.
|
110
|
+
def applied?
|
111
|
+
@applied
|
112
|
+
end
|
113
|
+
|
114
|
+
# Whether the Proc should be evaluated or skipped. By default, this depends
|
115
|
+
# on whether the QueryBuilder is meant to be rendered ot not, and on the
|
116
|
+
# +if+ and +unless+ options.
|
117
|
+
#
|
118
|
+
# Subclasses should override this method to provide specific reasons why the
|
119
|
+
# QueryBuilder should be skipped or not.
|
120
|
+
#
|
121
|
+
# @return [Boolean]
|
122
|
+
# @see #render?
|
123
|
+
def evaluate?
|
124
|
+
render?
|
125
|
+
end
|
126
|
+
|
127
|
+
# Whether this QueryBuilder should be displayed in views or not. By default
|
128
|
+
# this depends on the +if+ and +unless+ options.
|
129
|
+
#
|
130
|
+
# Subclasses should override this method to provide specific reasons why the
|
131
|
+
# QueryBuilder should be rendered or not.
|
132
|
+
#
|
133
|
+
# @return [Boolean]
|
134
|
+
# @see #evaluate?
|
135
|
+
def render?
|
136
|
+
evaluate(with: options[:if]) && !evaluate(with: options[:unless])
|
137
|
+
end
|
138
|
+
|
139
|
+
# Finds values in your I18n configuration based on this QueryBuilder's
|
140
|
+
# {name} and {context}.
|
141
|
+
#
|
142
|
+
# @example Looking up strings in the i18n sources
|
143
|
+
# class SomeQuery
|
144
|
+
# extend ActiveModel::Translation
|
145
|
+
#
|
146
|
+
# def self.i18n_scope
|
147
|
+
# :queries
|
148
|
+
# end
|
149
|
+
# end
|
150
|
+
#
|
151
|
+
# builder = QueryBuilder.new(:builder_name, context: SomeQuery.new)
|
152
|
+
#
|
153
|
+
# # Assuming the current locale is `en`, this will search for:
|
154
|
+
# #
|
155
|
+
# # en: # Current Locale
|
156
|
+
# # queries: # Context's i18n_scope
|
157
|
+
# # labels: # Namespace given to this method
|
158
|
+
# # some_query: # Context's model_name
|
159
|
+
# # builder_name: <value> # This builder's name.
|
160
|
+
# #
|
161
|
+
# builder.i18n_lookup(:labels)
|
162
|
+
#
|
163
|
+
# @example i18n_lookup follows the context's inheritance chain
|
164
|
+
# class BaseQuery
|
165
|
+
# extend ActiveModel::Translation
|
166
|
+
#
|
167
|
+
# def self.i18n_scope
|
168
|
+
# :queries
|
169
|
+
# end
|
170
|
+
# end
|
171
|
+
#
|
172
|
+
# class ConcreteQuery < BaseQuery
|
173
|
+
# end
|
174
|
+
#
|
175
|
+
# builder = QueryBuilder.new(:builder_name, context: SomeQuery.new)
|
176
|
+
#
|
177
|
+
# # Assuming the current locale is `en`, this will first attempt to search
|
178
|
+
# # for:
|
179
|
+
# #
|
180
|
+
# # en.queries.labels.concrete_query.builder_name
|
181
|
+
# #
|
182
|
+
# # And if no translation is declared, will then look up:
|
183
|
+
# #
|
184
|
+
# # en.queries.labels.base_query.builder_name
|
185
|
+
# #
|
186
|
+
# builder.i18n_lookup(:labels)
|
187
|
+
#
|
188
|
+
# @param namespace [Symbol] A scope to find I18n values in.
|
189
|
+
# @param default [String, nil] A default value to render if no value is
|
190
|
+
# found in the i18n sources.
|
191
|
+
#
|
192
|
+
# @see HasI18n#i18n_lookup
|
193
|
+
def i18n_lookup(namespace, default: nil)
|
194
|
+
super(name, namespace, default: default, context: options[:context])
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/core_ext/module/delegation"
|
4
|
+
require_relative "has_options"
|
5
|
+
|
6
|
+
module DTB
|
7
|
+
# These models a set of {QueryBuilder} objects where you can quickly select
|
8
|
+
# sub-sets of query builders or extract individual builders.
|
9
|
+
#
|
10
|
+
# A set can also be evaluated using the method {#call}, which will evaluate
|
11
|
+
# each query builder in the set in turn, using each builder's output as the
|
12
|
+
# next's buidler's input. This allows, for example, applying every filter or
|
13
|
+
# column defined in a query in a single method call.
|
14
|
+
class QueryBuilderSet
|
15
|
+
include HasOptions
|
16
|
+
|
17
|
+
# @!method each(&block)
|
18
|
+
# Iterate through the builders in the set.
|
19
|
+
# @yieldparam builder [QueryBuilder] A QueryBuilder
|
20
|
+
# @return [void]
|
21
|
+
|
22
|
+
# @!method to_a
|
23
|
+
# @return [Array<QueryBuilder>] an Array with the contents of the set.
|
24
|
+
|
25
|
+
# @!method any?
|
26
|
+
# @return [Boolean] if the set has at least one {QueryBuilder}.
|
27
|
+
|
28
|
+
# @!method empty?
|
29
|
+
# @return [Boolean] if the set is empty.
|
30
|
+
|
31
|
+
delegate :each, :to_a, :any?, :empty?, to: :@builders
|
32
|
+
|
33
|
+
# @param builders [Array<QueryBuilder>]
|
34
|
+
# @param opts [Hash] any defined options.
|
35
|
+
# @raise (see HasOptions#initialize)
|
36
|
+
def initialize(builders = [], opts = {})
|
37
|
+
super(opts)
|
38
|
+
@builders = builders
|
39
|
+
end
|
40
|
+
|
41
|
+
# Evaluates every {QueryBuilder} in the set if necessary, passing the return
|
42
|
+
# value of each as input to the next builder's {QueryBuilder#call} method.
|
43
|
+
#
|
44
|
+
# @example Applying multiple builders at once.
|
45
|
+
#
|
46
|
+
# builder_1 = QueryBuilder.new(...)
|
47
|
+
# builder_2 = QueryBuilder.new(...)
|
48
|
+
# builder_3 = QueryBuilder.new(...)
|
49
|
+
#
|
50
|
+
# builders = QueryBuilderSet.new([builder_1, builder_2, builder_3])
|
51
|
+
#
|
52
|
+
# # This will evaluate all three builders
|
53
|
+
# result = builders.call(a_scope)
|
54
|
+
#
|
55
|
+
# # ...and is equivalent to doing this:
|
56
|
+
# a_scope = builder_1.call(a_scope)
|
57
|
+
# a_scope = builder_2.call(a_scope)
|
58
|
+
# result = builder_3.call(a_scope)
|
59
|
+
#
|
60
|
+
# @param scope (see QueryBuilder#call)
|
61
|
+
# @return (see QueryBuilder#call)
|
62
|
+
#
|
63
|
+
# @see QueryBuilder#call
|
64
|
+
def call(scope)
|
65
|
+
@builders.reduce(scope) { |current, builder| builder.call(current) }
|
66
|
+
end
|
67
|
+
|
68
|
+
# Filters the set to only those {QueryBuilder}s that should be rendered.
|
69
|
+
#
|
70
|
+
# @return [QueryBuilderset] a new set.
|
71
|
+
def renderable
|
72
|
+
self.class.new(@builders.select { |builder| builder.render? }, options)
|
73
|
+
end
|
74
|
+
|
75
|
+
# Filters the set to only those {QueryBuilder}s that have been applied.
|
76
|
+
#
|
77
|
+
# @return [QueryBuilderset] a new set.
|
78
|
+
def applied
|
79
|
+
self.class.new(@builders.select { |builder| builder.applied? }, options)
|
80
|
+
end
|
81
|
+
|
82
|
+
# @param name [Symbol]
|
83
|
+
# @return [QueryBuilder, nil] a single QueryBuilder, by name, if it's
|
84
|
+
# currently in the set, or +nil+ if it's not.
|
85
|
+
def [](name)
|
86
|
+
@builders.find { |builder| builder.name.to_s == name.to_s }
|
87
|
+
end
|
88
|
+
|
89
|
+
# @param names [Array<Symbol>] Splat of names to filter.
|
90
|
+
# @return [QueryBuilderSet] a subset containing only the builders with the
|
91
|
+
# names in the input list.
|
92
|
+
def slice(*names)
|
93
|
+
builders = @builders.select do |builder|
|
94
|
+
names.any? { |name| name.to_s == builder.name.to_s }
|
95
|
+
end
|
96
|
+
|
97
|
+
self.class.new(builders, options)
|
98
|
+
end
|
99
|
+
|
100
|
+
# @param names [Array<Symbol>] Splat of names to filter out.
|
101
|
+
# @return [QueryBuilderSet] a subset containing only the builders in this
|
102
|
+
# set whose name is not in the input list.
|
103
|
+
def except(*names)
|
104
|
+
builders = @builders.reject do |builder|
|
105
|
+
names.any? { |name| name.to_s == builder.name.to_s }
|
106
|
+
end
|
107
|
+
self.class.new(builders, options)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,171 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/concern"
|
4
|
+
require "active_model/naming"
|
5
|
+
require_relative "has_options"
|
6
|
+
|
7
|
+
module DTB
|
8
|
+
# Provides a simple abstraction for rendering components by setting an option
|
9
|
+
# with the object to use for rendering.
|
10
|
+
#
|
11
|
+
# This povides a {#renderer} method that you can pass to ActionView's
|
12
|
+
# +render+, like so:
|
13
|
+
#
|
14
|
+
# <%= render @object.renderer %>
|
15
|
+
#
|
16
|
+
# == Rendering partials
|
17
|
+
#
|
18
|
+
# In the most basic case, you can pass a string to the +:render_with+ option
|
19
|
+
# to render the component via a partial. When doing so, the return value of
|
20
|
+
# the {#rendering_options} method will be passed as locals.
|
21
|
+
#
|
22
|
+
# @example Rendering via a partial
|
23
|
+
# class SelectFilter < DTB::Filter
|
24
|
+
# option :render_with, default: "filters/select_filter"
|
25
|
+
# end
|
26
|
+
#
|
27
|
+
# In that example, +renderer+ will return a Hash that looks like this:
|
28
|
+
#
|
29
|
+
# {partial: "filters/select_filter", locals: {filter: <the filter object>}}
|
30
|
+
#
|
31
|
+
# == Rendering components
|
32
|
+
#
|
33
|
+
# If you're using a component library such as ViewComponent or Phlex, you can
|
34
|
+
# pass a component directly to the +:render_with+ option. The component will
|
35
|
+
# be instantiated with the object.
|
36
|
+
#
|
37
|
+
# @example Rendering a ViewComponent
|
38
|
+
# class SelectFilter < DTB::Filter
|
39
|
+
# option :render_with, default: SelectFilterComponent
|
40
|
+
# end
|
41
|
+
#
|
42
|
+
# In that example, calling +renderer+ will be equivalent to insantiating the
|
43
|
+
# component like so:
|
44
|
+
#
|
45
|
+
# SelectFilterComponent.new(filter: <the filter object>)
|
46
|
+
#
|
47
|
+
# == Dynamic renderer resolution
|
48
|
+
#
|
49
|
+
# If you pass a callable to +:render_with+, it will be called with the object
|
50
|
+
# as a keyword argument. The callable is expected to return a valid argument
|
51
|
+
# for ActionView's +render+ method.
|
52
|
+
#
|
53
|
+
# @example Dynamic partial selection
|
54
|
+
# class SelectFilter < DTB::Filter
|
55
|
+
# option :render_with, default: ->(filter:, **opts) {
|
56
|
+
# if filter.autocomplete?
|
57
|
+
# {partial: "filters/autocomplete_filter", locals: {filter: filter, **opts}}
|
58
|
+
# else
|
59
|
+
# {partial: "filters/select_filter", locals: {filter: filter, **opts}}
|
60
|
+
# end
|
61
|
+
# }
|
62
|
+
# end
|
63
|
+
#
|
64
|
+
# == Passing extra options to the renderer
|
65
|
+
#
|
66
|
+
# Whatever options you pass to the {#renderer} method, they will be
|
67
|
+
# forwarded to the configured renderer via {#render_with}.
|
68
|
+
#
|
69
|
+
# If rendering with a partial, these will be passed as extra locals. If using
|
70
|
+
# a component-based renderer, these will be passed as extra keyword arguments
|
71
|
+
# to the initializer.
|
72
|
+
#
|
73
|
+
# @example Passing extra locals to a partial
|
74
|
+
# <%= render filter.renderer(css_class: "custom-class") %>
|
75
|
+
module Renderable
|
76
|
+
extend ActiveSupport::Concern
|
77
|
+
include HasOptions
|
78
|
+
|
79
|
+
included do
|
80
|
+
# @!group Options
|
81
|
+
|
82
|
+
# @!attribute [rw] render_with
|
83
|
+
# @return [#call, Class<#render_in>, Hash] an object that can be used to
|
84
|
+
# render the Renderable, that will be passed to ActionView's #render.
|
85
|
+
# @see #renderer
|
86
|
+
# @see #rendering_options
|
87
|
+
option :render_with
|
88
|
+
|
89
|
+
# @!endgroup
|
90
|
+
end
|
91
|
+
|
92
|
+
# Returns an object capable of being rendered by ActionView's +render+,
|
93
|
+
# based on what the +:render_with+ option is set to.
|
94
|
+
#
|
95
|
+
# * If +:render_with+ is a string, it will return a Hash with the +:partial+
|
96
|
+
# key set to the string, and the +:locals+ key set to the return value of
|
97
|
+
# the {#rendering_options} method, plus any extra options passed to this
|
98
|
+
# method.
|
99
|
+
#
|
100
|
+
# * If +:render_with+ is a class, it will return an instance of that class
|
101
|
+
# with the return value of the {#rendering_options} method and any extra
|
102
|
+
# options passed to this method as keyword arguments.
|
103
|
+
#
|
104
|
+
# * If +:render_with+ is a callable, it will call it with the return value
|
105
|
+
# of the {#rendering_options} method and any extra options passed to this
|
106
|
+
# method as keyword arguments.
|
107
|
+
#
|
108
|
+
# @param opts [Hash] extra options to pass to the renderer.
|
109
|
+
# @return [Hash, #render_in] an object that can be used as an argument to
|
110
|
+
# ActionView's +render+ method.
|
111
|
+
#
|
112
|
+
# @see #rendering_options
|
113
|
+
def renderer(**opts)
|
114
|
+
render_with = options[:render_with]
|
115
|
+
opts = opts.update(rendering_options)
|
116
|
+
|
117
|
+
if render_with.respond_to?(:call)
|
118
|
+
render_with.call(**opts)
|
119
|
+
elsif render_with.is_a?(Class)
|
120
|
+
render_with.new(**opts)
|
121
|
+
elsif render_with.respond_to?(:to_str)
|
122
|
+
{partial: render_with, locals: opts}
|
123
|
+
else
|
124
|
+
render_with
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
# Returns a Hash of options to pass to the renderer. By default, this will
|
129
|
+
# include a reference to the Renderable itself, under a key that is derived
|
130
|
+
# from its class name, underscored, after removing any class namespace.
|
131
|
+
#
|
132
|
+
# @example Default rendering options
|
133
|
+
# class MyFilter
|
134
|
+
# include DTB::Renderable
|
135
|
+
# end
|
136
|
+
#
|
137
|
+
# filter = MyFilter.new
|
138
|
+
# filter.rendering_options # => {my_filter: filter}
|
139
|
+
#
|
140
|
+
# @example Default rendering options in a namespaced object
|
141
|
+
# class Admin::Widget
|
142
|
+
# include DTB::Renderable
|
143
|
+
# end
|
144
|
+
#
|
145
|
+
# widget = Admin::Widget.new
|
146
|
+
# widget.rendering_options # => {widget: widget}
|
147
|
+
#
|
148
|
+
# @example Overridden rendering options
|
149
|
+
# class MyFilter
|
150
|
+
# include DTB::Renderable
|
151
|
+
#
|
152
|
+
# def rendering_options
|
153
|
+
# {filter: self, custom: "option"}
|
154
|
+
# end
|
155
|
+
# end
|
156
|
+
#
|
157
|
+
# filter = MyFilter.new
|
158
|
+
# filter.rendering_options # => {filter: filter, custom: "option"}
|
159
|
+
#
|
160
|
+
# @return [Hash<Symbol, Object>]
|
161
|
+
def rendering_options
|
162
|
+
name = ActiveModel::Name.new(self.class).element.underscore.to_sym
|
163
|
+
{name => self}
|
164
|
+
end
|
165
|
+
|
166
|
+
# (see BuildsDataTable#to_data_table)
|
167
|
+
def to_data_table(*)
|
168
|
+
super.merge(renderable: self)
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
data/lib/dtb/version.rb
ADDED