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
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