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.
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require_relative "errors"
5
+
6
+ module DTB
7
+ # Provides a base implementation for running a Query, which can be expanded on
8
+ # by extending the #run method.
9
+ #
10
+ # In their simplest form, queries provide a default scope, and then all
11
+ # filters, and columns are applied automatically on top.
12
+ #
13
+ # If a query does not provide a default scope, then it should override the run
14
+ # method to craft the query, which might be required for more complex queries.
15
+ #
16
+ # @example Defining a default_scope on a query
17
+ # class OrdersQuery < DTB::Query
18
+ # default_scope { Current.user.orders }
19
+ #
20
+ # column :number, ->(scope) { scope.select(:number, :id) }
21
+ # column :buyer, ->(scope) { scope.select(:buyer_id).includes(:buyer) }
22
+ # # ...
23
+ # end
24
+ #
25
+ # @example Overwriting the #run method for more control
26
+ # class OrdersQuery < DTB::Query
27
+ # column :number, ->(scope) { scope.select(:number, :id) }
28
+ # column :buyer, ->(scope) { scope.select(:buyer_id).includes(:buyer) }
29
+ # # ...
30
+ #
31
+ # def run
32
+ # scope = Current.user.orders
33
+ # scope = columns.call(scope)
34
+ # scope = filters.call(scope)
35
+ # scope
36
+ # end
37
+ # end
38
+ #
39
+ module HasDefaultImplementation
40
+ extend ActiveSupport::Concern
41
+
42
+ class_methods do
43
+ # Define the default scope for this query.
44
+ #
45
+ # @yield a block that should return an initial scope for the query.
46
+ # @yieldreturn [Object] an object compatible with your
47
+ # {QueryBuilder} proc's input.
48
+ # @return [void]
49
+ def default_scope(&block)
50
+ @default_scope = block if block
51
+ @default_scope
52
+ end
53
+ end
54
+
55
+ # @return [Object, nil] the default scope defined for this query, if any.
56
+ def default_scope
57
+ self.class.default_scope
58
+ end
59
+
60
+ # Runs the query, returning the result of applying all the query builders on
61
+ # top of the default scope.
62
+ #
63
+ # @return [Object] the result of running the query.
64
+ # @raise {NotImplementedError} if no default scope is defined.
65
+ def run
66
+ if default_scope
67
+ instance_exec(&default_scope)
68
+ else
69
+ fail DTB::NotImplementedError, <<~ERROR
70
+ Either add a `default_scope` to your Query to apply all columns and
71
+ filters by default, or override the `#run` method to manually build
72
+ the query from the respective atoms.
73
+ ERROR
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require_relative "empty_state"
5
+ require_relative "has_options"
6
+
7
+ module DTB
8
+ # This mixin provides access to {EmptyState empty state configuration} to both
9
+ # queries and data tables.
10
+ #
11
+ # @example Configuring a default partial to render empty states
12
+ # class ApplicationQuery < DTB::Query
13
+ # options[:empty_state][:render_with] = "data_tables/empty_state"
14
+ # end
15
+ #
16
+ # @example Rendering the empty state of a data table
17
+ # <% if data_table.empty? %>
18
+ # <%= render data_table.empty_state.renderer(data_table: data_table) %>
19
+ # <% end %>
20
+ #
21
+ # @example A sample default empty state partial
22
+ # <div class="empty_state">
23
+ # <h2><%= empty_state.title %><h2>
24
+ # <p><%= empty_state.explanation %></p>
25
+ #
26
+ # <% if data_table.filtered? %>
27
+ # <p><%= empty_state.update_filters %></p>
28
+ # <% end %>
29
+ # <div>
30
+ module HasEmptyState
31
+ extend ActiveSupport::Concern
32
+ include HasOptions
33
+
34
+ included do
35
+ # @!group Options
36
+
37
+ # @!attribute [rw] empty_state
38
+ # @return [OptionsMap] a set of options for handling the empty state.
39
+ # @see EmptyState
40
+ nested_options :empty_state, EmptyState.options
41
+
42
+ # @!endgroup
43
+ end
44
+
45
+ # @return [EmptyState] access information about the empty state to render
46
+ # for this query, if there are no results.
47
+ def empty_state
48
+ @empty_state ||= EmptyState.new(options[:empty_state].merge(context: self))
49
+ end
50
+
51
+ # (see BuildsDataTable#to_data_table)
52
+ def to_data_table
53
+ super.merge(empty_state: empty_state)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require_relative "builds_data_table"
5
+ require_relative "filter"
6
+ require_relative "filter_set"
7
+ require_relative "has_default_implementation"
8
+ require_relative "has_options"
9
+ require_relative "has_url"
10
+
11
+ module DTB
12
+ # This mixin provides {Query Queries} with a set of filter objects that can be
13
+ # used to modify the query and to render the filters form in the view.
14
+ # Including this module gives you access to the {.filter} class method, which
15
+ # you can use to define filters in your query.
16
+ #
17
+ # @example (see .filter)
18
+ module HasFilters
19
+ extend ActiveSupport::Concern
20
+ include HasDefaultImplementation
21
+ include BuildsDataTable
22
+ include HasOptions
23
+ include HasUrl
24
+
25
+ included do
26
+ # @!group Options
27
+
28
+ # @!attribute [rw] filters
29
+ # @return [OptionsMap] a set of options for handling the filters form.
30
+ # @see FilterSet
31
+ nested_options :filters, FilterSet.options
32
+
33
+ # @!attribute [rw] default_params
34
+ # @return [Hash] the Hash of parameters to use when no filters are
35
+ # defined by users.
36
+ option :default_params, default: {}
37
+
38
+ # @!attribute [rw] default_filter_type
39
+ # @return [Class<Filter>] the default subclass of {Filter} to use unless
40
+ # one is specified.
41
+ # @see .filter
42
+ option :default_filter_type, default: Filter
43
+
44
+ # @!endgroup
45
+ end
46
+
47
+ class_methods do
48
+ # Defines a new Filter that will be added to this Query.
49
+ #
50
+ # @example Adding a filter to match the +name+ column to the input value exactly
51
+ # filter :name
52
+ #
53
+ # @example Adding a filter to find things with a name that contains the value
54
+ # filter :name,
55
+ # ->(scope, value) { scope.where("name ILIKE ?", "%#{value}%") }
56
+ #
57
+ # @example Overriding the type of filter object
58
+ # filter :name,
59
+ # type: ContainsTextFilter
60
+ #
61
+ # @example Overriding the renderer used for a specific filter
62
+ # # Instead of rendering "filters/contains_text_filter", this would
63
+ # # render "example/partial" in the filters form.
64
+ # filter :name,
65
+ # type: ContainsTextFilter,
66
+ # partial: "example/partial"
67
+ #
68
+ # @example Add a filter only if the user has permissions
69
+ # filter :name,
70
+ # type: ContainsTextFilter,
71
+ # if: -> { Current.user.has_permission? }
72
+ #
73
+ # @param name [Symbol]
74
+ # @param query [Proc] The filter's {QueryBuilder} proc. This proc should
75
+ # receive two parameters: the query's current +scope+ and the filter's
76
+ # +value+ and should return a modified +scope+.
77
+ #
78
+ # By default, this will add a simple
79
+ # {https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-where +where+} clause
80
+ # for matching a column with the same +name+ as the filter having an
81
+ # exact match on the proc's input +value+.
82
+ # @param type [Class<Column>] The type of filter to use. Defaults to
83
+ # whatever is set as the {default_filter_type}.
84
+ # @param opts [Hash] Any other options required by the +type+.
85
+ # @return [void]
86
+ def filter(name, query = ->(scope, value) { scope.where(name => value) }, type: options[:default_filter_type], **opts)
87
+ filter_definitions << {type: type, name: name, query: query, options: opts}
88
+ end
89
+
90
+ # @api private
91
+ # @return [Array<Hash>]
92
+ def filter_definitions
93
+ @filter_definitions ||= []
94
+ end
95
+ end
96
+
97
+ # @return [Hash] the input parameters including the filters.
98
+ attr_reader :params
99
+
100
+ # @return [FilterSet] the set of filters defined on this object.
101
+ def filters
102
+ return @filters if defined?(@filters)
103
+
104
+ values = params.fetch(options[:filters][:param], options[:default_params])
105
+
106
+ filters = self.class.filter_definitions.map do |dfn|
107
+ name = dfn[:name]
108
+ dfn[:type].new(name, value: values[name], context: self, **dfn[:options], &dfn[:query])
109
+ end
110
+
111
+ filter_options = {submit_url: url, reset_url: reset_url}
112
+ .merge(options[:filters])
113
+ .compact
114
+
115
+ @filters = FilterSet.new(filters, filter_options)
116
+ end
117
+
118
+ # @return [String, nil] the URL to reset the filters and go back to the
119
+ # initial state. Defaults to removing the configured {FilterSet#param
120
+ # filters' param name} from the query string.
121
+ def reset_url
122
+ @filters_reset_url ||= override_query_params(
123
+ options[:filters][:param] => nil
124
+ )
125
+ end
126
+
127
+ # @overload initialize(params = {}, options = {})
128
+ # @param params [Hash] the Hash of params submitted by the user. These will
129
+ # be accessible within the Query as {params}.
130
+ # @param options [Hash] the Hash of {options} to configure this object.
131
+ # @see HasOptions#initialize
132
+ def initialize(params = {}, *args, &block)
133
+ super(*args, &block)
134
+ @params = params
135
+ end
136
+
137
+ # Applies all defined filters to the query being built.
138
+ #
139
+ # @return (see HasDefaultImplementation#run)
140
+ def run
141
+ filters.call(super)
142
+ end
143
+
144
+ # (see BuildsDataTable#to_data_table)
145
+ def to_data_table
146
+ super.merge(filters: filters)
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "i18n"
5
+
6
+ module DTB
7
+ # This mixin provides a helper to lookup translations using the I18n gem's
8
+ # configured backends.
9
+ #
10
+ # By default, given a name and namespace, it will look up the translation
11
+ # under +{namespace}.{name}+. You can also give it a context object, and then
12
+ # if that object implements +ActiveModel::Translation+, it will first try to
13
+ # look it up using the +ActiveModel::Translation+ inheritance chain.
14
+ #
15
+ # Because {Query} objects implement +ActiveModel::Translation+, all lookups
16
+ # performed in the context of a Query object will use this strategy. See the
17
+ # examples below.
18
+ #
19
+ # Finally, a default can be provided, and it will be returned if none of the
20
+ # searched keys contain a translation.
21
+ #
22
+ # @example Looking up a translation without a context object
23
+ # # Because no context object is given, this will only look for `labels.foo`
24
+ # i18n_lookup(:foo, :labels)
25
+ #
26
+ # @example Looking up a translation with a plain context object
27
+ # # In this case, `context` does not implement `ActiveModel::Translation`,
28
+ # # so it will again, only look for `labels.foo`.
29
+ # i18n_lookup(:foo, :labels, context: Object.new)
30
+ #
31
+ # @example Looking up a translation within a Query object.
32
+ # # Queries implement ActiveModel::Translation, and provide a base i18n
33
+ # # scope of `queries`. If we have this query object:
34
+ # class SomeQuery < DTB::Query
35
+ # end
36
+ #
37
+ # # Then this will look up the translation in this priority order:
38
+ # #
39
+ # # 1. queries.labels.some_query.foo
40
+ # # 2. queries.labels.dtb/query.foo
41
+ # # 3. labels.foo
42
+ # #
43
+ # i18n_lookup(:foo, :labels, context: SomeQuery.new)
44
+ #
45
+ # @see https://api.rubyonrails.org/classes/ActiveModel/Translation.html
46
+ module HasI18n
47
+ extend ActiveSupport::Concern
48
+
49
+ # Look for a translation in the configured I18n backend.
50
+ #
51
+ # @param name [Symbol] the name of an attribute.
52
+ # @param namespace [Symbol] a namespace within the i18n sources.
53
+ # @param default [Object, nil] what to return if the given +name+/+namespace+
54
+ # combination isn't found.
55
+ # @param context [Class<ActiveModel::Translation>, nil] a context object to
56
+ # lookup translations following an inheritance chain.
57
+ # @see https://api.rubyonrails.org/classes/ActiveModel/Translation.html
58
+ def i18n_lookup(name, namespace, default: nil, context: nil)
59
+ defaults = []
60
+
61
+ if defined?(ActiveModel::Translation) && context.class.is_a?(ActiveModel::Translation)
62
+ scope = "#{context.class.i18n_scope}.#{namespace}"
63
+
64
+ defaults.concat(context.class.lookup_ancestors
65
+ .map { |klass| :"#{scope}.#{klass.model_name.i18n_key}.#{name}" })
66
+ end
67
+
68
+ defaults << :"#{namespace}.#{name}" << default
69
+
70
+ I18n.translate(defaults.shift, default: defaults)
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/class/attribute"
5
+ require "active_support/core_ext/hash/deep_merge"
6
+ require_relative "options_map"
7
+
8
+ module DTB
9
+ # Mixin that provides classes with the ability to define options that can be
10
+ # validated when initializing the object.
11
+ module HasOptions
12
+ extend ActiveSupport::Concern
13
+
14
+ included do
15
+ # @!attribute [rw] options
16
+ # @return [OptionsMap]
17
+ class_attribute :options, instance_predicate: false
18
+
19
+ self.options = OptionsMap.new
20
+ end
21
+
22
+ class_methods do
23
+ # Adds a valid option to this class, optionally marking it as required, or
24
+ # setting a default value.
25
+ #
26
+ # @example Defining options
27
+ # class SomeObject
28
+ # include DTB::HasOptions
29
+ #
30
+ # option :foo, required: true
31
+ # option :bar, default: 1
32
+ # end
33
+ #
34
+ # obj = SomeObject.new(foo: "test")
35
+ # obj.options #=> {foo: "test", bar: 1}
36
+ #
37
+ # @param name [Symbol]
38
+ # @param default The default value.
39
+ # @param required [Boolean] Whether to validate that the option is set
40
+ # when instantiating the object.
41
+ # @return [void]
42
+ #
43
+ # @!macro [attach] option
44
+ # @option options $1
45
+ def option(name, default: OptionsMap::UNSET_OPTION, required: false)
46
+ self.options = options.define(name, default: default, required: required)
47
+ end
48
+
49
+ # Adds a set of nested options to this class, matching a nested schema.
50
+ #
51
+ # @example Defining nested options
52
+ # class Component
53
+ # include DTB::HasOptions
54
+ #
55
+ # option :foo
56
+ # option :bar, default: "test"
57
+ # end
58
+ #
59
+ # class Container
60
+ # include DTB::HasOptions
61
+ #
62
+ # option :baz
63
+ # nested_options :component, Component.options
64
+ # end
65
+ #
66
+ # container = Container.new(baz: 1, component: {foo: 2})
67
+ # container.options #=> {baz: 1, component: {foo: 2, bar: "test"}}
68
+ #
69
+ # @param name [Symbol] The name to nest the options under.
70
+ # @param opts [OptionsMap] A map of options to use as a schema and default
71
+ # values.
72
+ # @return [void]
73
+ def nested_options(name, opts = OptionsMap.new)
74
+ self.options = options.nest(name, opts)
75
+ end
76
+ end
77
+
78
+ # @param opts [Hash] An Options Hash. Options need to conform to the schema
79
+ # defined via calls to {.option} and {.nested_options}.
80
+ # @raise [UnknownOptionsError] if given an option that was not defined via
81
+ # {.option} or {.nested_options}.
82
+ # @raise [MissingOptionsError] if missing an option marked as +required+ via
83
+ # {.option}
84
+ def initialize(opts = {})
85
+ self.options = options.deep_merge(opts).validate!
86
+ options.freeze
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "active_support/concern"
5
+ require "active_support/core_ext/hash/indifferent_access"
6
+ require "active_support/core_ext/hash/deep_merge"
7
+ require "active_support/core_ext/object/blank"
8
+ require "active_support/core_ext/object/to_query"
9
+ require "rack/utils"
10
+ require_relative "has_options"
11
+
12
+ module DTB
13
+ # Allows objects to support having a URL, and provides a modifier method to
14
+ # change the query params on that URL (or any other URL-like String). This is
15
+ # useful to set a base URL in the options, and then be able to derive multiple
16
+ # URLs from that one.
17
+ module HasUrl
18
+ extend ActiveSupport::Concern
19
+ include HasOptions
20
+
21
+ included do
22
+ # @!group Options
23
+
24
+ # @!attribute [rw] url
25
+ # @return [String, nil] A Base URL
26
+ # @see HasFilters
27
+ option :url
28
+
29
+ # @!endgroup
30
+ end
31
+
32
+ def url
33
+ @url ||= options[:url]
34
+ end
35
+
36
+ # Returns a copy of the given URL (by default, the Base URL set via the
37
+ # +:url+ option), with overridden query parameters.
38
+ #
39
+ # @example Add a query parameter
40
+ # object.options[:url] = "/test?foo=1"
41
+ # object.override_query_params(bar: 2) #=> "/test?foo=1&bar=2"
42
+ #
43
+ # @example Remove query parameters
44
+ # object.options[:url] = "/test?foo=1"
45
+ # object.override_query_params(foo: nil) #=> "/test"
46
+ #
47
+ # @example Override a different URL
48
+ # object.override_query_params("/list", foo: 1) #=> "/list?foo=1"
49
+ #
50
+ # @param base_url [String, nil] The URL to modify. If +nil+, this method does
51
+ # nothing and returns +nil+.
52
+ # @param query [Hash] A Hash of query parameters to use.
53
+ # @return [String, nil]
54
+ def override_query_params(base_url = url, query = {})
55
+ base_url, query = url, base_url if base_url.is_a?(Hash)
56
+ return if base_url.nil?
57
+
58
+ uri = URI.parse(base_url)
59
+ params = Rack::Utils.parse_nested_query(uri.query).with_indifferent_access
60
+ uri.query = params.deep_merge(query).compact.to_query.presence
61
+ uri.to_s
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/deep_dup"
4
+ require_relative "errors"
5
+
6
+ module DTB
7
+ # Extends +Hash+ to allow for a lightweight "schema" of sorts. You can define
8
+ # which keys are allowed and which keys are required to be present, and then
9
+ # validate that the Hash meets this criteria.
10
+ #
11
+ # options = OptionsMap.new
12
+ # options.define!(:foo, required: true)
13
+ # options.define!(:bar, default: 1)
14
+ #
15
+ # options #=> { bar: 1 }
16
+ # options.validate! #=> raises MissingOptionsError
17
+ #
18
+ # options.update(foo: 2)
19
+ # options.validate! #=> options
20
+ #
21
+ # Option Maps can also define "nested" maps of options, by using another
22
+ # +OptionsMap+ as a template. This is useful for top level objects that accept
23
+ # options for nested objects.
24
+ #
25
+ # component_options = OptionsMap.new
26
+ # component_options.define!(:foo, required: true)
27
+ #
28
+ # top_level_options = OptionsMap.new
29
+ # top_level_options.define!(:bar, default: true)
30
+ # top_level_options.nest!(:component, component_options)
31
+ #
32
+ # top_level_options.update(bar: false, component: {foo: true})
33
+ # top_level_options.validate! #=> top_level_options
34
+ #
35
+ # @api private
36
+ # @see HasOptions
37
+ class OptionsMap < Hash
38
+ # @return [Set] The defined valid options.
39
+ attr_reader :valid_keys
40
+
41
+ # @return [Set] The options defined as required.
42
+ attr_reader :required_keys
43
+
44
+ def initialize(*) # :nodoc:
45
+ super
46
+ @valid_keys = Set.new
47
+ @required_keys = Set.new
48
+ @nested_options = {}
49
+ end
50
+
51
+ def initialize_copy(other) # :nodoc:
52
+ super
53
+ @valid_keys = other.valid_keys.dup
54
+ @required_keys = other.required_keys.dup
55
+ end
56
+
57
+ # Returns a copy of the options map with a new option defined.
58
+ #
59
+ # @param (see #define!)
60
+ # @return [OptionsMap] A new instance.
61
+ #
62
+ # @see HasOptions#option
63
+ def define(name, default: UNSET_OPTION, required: false)
64
+ deep_dup.define!(name, default: default, required: required)
65
+ end
66
+
67
+ # Defines a new option in this OptionsMap.
68
+ #
69
+ # @param name [Symbol]
70
+ # @param default [Object] A default value. If given, the options Hash will
71
+ # be updated to include this option with this value.
72
+ # @param required [Boolean]
73
+ # @return [self]
74
+ def define!(name, default: UNSET_OPTION, required: false)
75
+ valid_keys << name
76
+ required_keys << name if required
77
+ update(name => default) if default != UNSET_OPTION
78
+ self
79
+ end
80
+
81
+ # Returns a copy of the options map which allows a nested set of options
82
+ # that conforms to a specific schema.
83
+ #
84
+ # @param (see #nest!)
85
+ # @return [OptionsMap] A new instance.
86
+ #
87
+ # @see HasOptions#nested_options
88
+ def nest(name, options = self.class.new)
89
+ deep_dup.nest!(name, options)
90
+ end
91
+
92
+ # Defines a new set of nested options.
93
+ #
94
+ # @example
95
+ #
96
+ # component_options = OptionsMap.new
97
+ # component_options.define!(:foo)
98
+ # component_options.define!(:bar)
99
+ #
100
+ # top_level_options = OptionsMap.new
101
+ # top_level_options.define!(:qux)
102
+ # top_level_options.nest!(:nested, component_options)
103
+ #
104
+ # top_level_options.update(qux: 1, nested: {foo: 2, bar: 3})
105
+ #
106
+ # @param name [Symbol]
107
+ # @param options [OptionsMap] The schema for the nested options.
108
+ # @return [self]
109
+ def nest!(name, options = self.class.new)
110
+ valid_keys << name
111
+ @nested_options[name] = options
112
+ self[name] = options.deep_dup
113
+ self
114
+ end
115
+
116
+ # Enforces that all keys are defined as an option or nested options, that
117
+ # the required keys are all defined (irrespective of their value), and that
118
+ # all nested options hashes are equally valid.
119
+ #
120
+ # @return [self]
121
+ # @raise [UnknownOptionsError] if the Hash has any key that wasn't defined
122
+ # as an option, or if any nested options Hash has this problem.
123
+ # @raise [MissingOptionsError] if any of the required keys aren't defined or
124
+ # if any nested options Hash has this problem.
125
+ def validate!
126
+ fail UnknownOptionsError.new(self) if (keys.to_set - valid_keys).any?
127
+ fail MissingOptionsError.new(self) if (required_keys & keys) != required_keys
128
+
129
+ @nested_options.each do |key, schema|
130
+ options = self[key]
131
+ options = schema.merge(self[key]) unless options.respond_to?(:validate!)
132
+ options.validate!
133
+ end
134
+
135
+ self
136
+ end
137
+
138
+ # The default value for an option, which can be ignored. This allows specifying
139
+ # +nil+ as a valid default.
140
+ #
141
+ # @example
142
+ # options = OptionsMap.new
143
+ # options.define!(:foo, required: true)
144
+ # options.define!(:bar, default: nil)
145
+ # options #=> {bar: nil}
146
+ #
147
+ UNSET_OPTION = Object.new
148
+ end
149
+ end