dry-view 0.5.1 → 0.7.1

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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +143 -18
  3. data/LICENSE +20 -0
  4. data/README.md +22 -14
  5. data/dry-view.gemspec +29 -21
  6. data/lib/dry-view.rb +3 -1
  7. data/lib/dry/view.rb +514 -2
  8. data/lib/dry/view/context.rb +80 -0
  9. data/lib/dry/view/decorated_attributes.rb +82 -0
  10. data/lib/dry/view/errors.rb +29 -0
  11. data/lib/dry/view/exposure.rb +35 -14
  12. data/lib/dry/view/exposures.rb +18 -6
  13. data/lib/dry/view/part.rb +166 -53
  14. data/lib/dry/view/part_builder.rb +140 -0
  15. data/lib/dry/view/path.rb +35 -7
  16. data/lib/dry/view/render_environment.rb +62 -0
  17. data/lib/dry/view/render_environment_missing.rb +44 -0
  18. data/lib/dry/view/rendered.rb +55 -0
  19. data/lib/dry/view/renderer.rb +36 -29
  20. data/lib/dry/view/scope.rb +160 -14
  21. data/lib/dry/view/scope_builder.rb +98 -0
  22. data/lib/dry/view/tilt.rb +78 -0
  23. data/lib/dry/view/tilt/erb.rb +26 -0
  24. data/lib/dry/view/tilt/erbse.rb +21 -0
  25. data/lib/dry/view/tilt/haml.rb +26 -0
  26. data/lib/dry/view/version.rb +5 -2
  27. metadata +78 -115
  28. data/.gitignore +0 -26
  29. data/.rspec +0 -2
  30. data/.travis.yml +0 -23
  31. data/CONTRIBUTING.md +0 -29
  32. data/Gemfile +0 -22
  33. data/LICENSE.md +0 -10
  34. data/Rakefile +0 -6
  35. data/benchmarks/templates/button.html.erb +0 -1
  36. data/benchmarks/view.rb +0 -24
  37. data/bin/console +0 -7
  38. data/lib/dry/view/controller.rb +0 -155
  39. data/lib/dry/view/decorator.rb +0 -45
  40. data/lib/dry/view/missing_renderer.rb +0 -15
  41. data/spec/fixtures/templates/_hello.html.slim +0 -1
  42. data/spec/fixtures/templates/decorated_parts.html.slim +0 -4
  43. data/spec/fixtures/templates/edit.html.slim +0 -11
  44. data/spec/fixtures/templates/empty.html.slim +0 -1
  45. data/spec/fixtures/templates/greeting.html.slim +0 -2
  46. data/spec/fixtures/templates/hello.html.slim +0 -1
  47. data/spec/fixtures/templates/layouts/app.html.slim +0 -6
  48. data/spec/fixtures/templates/layouts/app.txt.erb +0 -3
  49. data/spec/fixtures/templates/parts_with_args.html.slim +0 -3
  50. data/spec/fixtures/templates/parts_with_args/_box.html.slim +0 -3
  51. data/spec/fixtures/templates/shared/_index_table.html.slim +0 -2
  52. data/spec/fixtures/templates/shared/_shared_hello.html.slim +0 -1
  53. data/spec/fixtures/templates/tasks.html.slim +0 -3
  54. data/spec/fixtures/templates/user.html.slim +0 -2
  55. data/spec/fixtures/templates/users.html.slim +0 -5
  56. data/spec/fixtures/templates/users.txt.erb +0 -3
  57. data/spec/fixtures/templates/users/_row.html.slim +0 -2
  58. data/spec/fixtures/templates/users/_tbody.html.slim +0 -5
  59. data/spec/fixtures/templates/users_with_count.html.slim +0 -5
  60. data/spec/fixtures/templates/users_with_count_inherit.html.slim +0 -6
  61. data/spec/fixtures/templates_override/_hello.html.slim +0 -1
  62. data/spec/fixtures/templates_override/users.html.slim +0 -5
  63. data/spec/integration/decorator_spec.rb +0 -80
  64. data/spec/integration/exposures_spec.rb +0 -392
  65. data/spec/integration/part/decorated_attributes_spec.rb +0 -157
  66. data/spec/integration/view_spec.rb +0 -133
  67. data/spec/spec_helper.rb +0 -46
  68. data/spec/unit/controller_spec.rb +0 -37
  69. data/spec/unit/decorator_spec.rb +0 -61
  70. data/spec/unit/exposure_spec.rb +0 -227
  71. data/spec/unit/exposures_spec.rb +0 -103
  72. data/spec/unit/part_spec.rb +0 -90
  73. data/spec/unit/renderer_spec.rb +0 -57
  74. data/spec/unit/scope_spec.rb +0 -53
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/core/equalizer"
4
+ require_relative "decorated_attributes"
5
+
6
+ module Dry
7
+ class View
8
+ # Provides a baseline environment across all the templates, parts and scopes
9
+ # in a given rendering.
10
+ #
11
+ # @abstract Subclass this and add your own methods (along with a custom
12
+ # `#initialize` if you wish to inject dependencies)
13
+ #
14
+ # @api public
15
+ class Context
16
+ include Dry::Equalizer(:_options)
17
+ include DecoratedAttributes
18
+
19
+ attr_reader :_render_env, :_options
20
+
21
+ # Returns a new instance of Context
22
+ #
23
+ # In subclasses, you should include an `**options` parameter and pass _all
24
+ # arguments_ to `super`. This allows Context to make copies of itself
25
+ # while preserving your dependencies.
26
+ #
27
+ # @example
28
+ # class MyContext < Dry::View::Context
29
+ # # Injected dependency
30
+ # attr_reader :assets
31
+ #
32
+ # def initialize(assets:, **options)
33
+ # @assets = assets
34
+ # super
35
+ # end
36
+ # end
37
+ #
38
+ # @api public
39
+ def initialize(render_env: nil, **options)
40
+ @_render_env = render_env
41
+ @_options = options
42
+ end
43
+
44
+ # @api private
45
+ def for_render_env(render_env)
46
+ return self if render_env == _render_env
47
+
48
+ self.class.new(**_options.merge(render_env: render_env))
49
+ end
50
+
51
+ # Returns a copy of the Context with new options merged in.
52
+ #
53
+ # This may be useful to supply values for dependencies that are _optional_
54
+ # when initializing your custom Context subclass.
55
+ #
56
+ # @example
57
+ # class MyContext < Dry::View::Context
58
+ # # Injected dependencies (request is optional)
59
+ # attr_reader :assets, :request
60
+ #
61
+ # def initialize(assets:, request: nil, **options)
62
+ # @assets = assets
63
+ # @request = reuqest
64
+ # super
65
+ # end
66
+ # end
67
+ #
68
+ # my_context = MyContext.new(assets: assets)
69
+ # my_context_with_request = my_context.with(request: request)
70
+ #
71
+ # @api public
72
+ def with(**new_options)
73
+ self.class.new(
74
+ render_env: _render_env,
75
+ **_options.merge(new_options)
76
+ )
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Dry
6
+ class View
7
+ # Decorates attributes in Parts.
8
+ module DecoratedAttributes
9
+ # @api private
10
+ def self.included(klass)
11
+ klass.extend ClassInterface
12
+ end
13
+
14
+ # Decorated attributes class-level interface.
15
+ module ClassInterface
16
+ # @api private
17
+ MODULE_NAME = :DecoratedAttributes
18
+
19
+ # Decorates the provided attributes, wrapping them in Parts using the
20
+ # current render environment.
21
+ #
22
+ # @example
23
+ # class Article < Dry::View::Part
24
+ # decorate :feature_image
25
+ # decorate :author as: :person
26
+ # end
27
+ #
28
+ # @param names [Array<Symbol>] the attribute names
29
+ # @param options [Hash] the options to pass to the Part Builder
30
+ # @option options [Symbol, Class] :as an alternative name or class to use when finding a
31
+ # matching Part
32
+ #
33
+ # @api public
34
+ def decorate(*names, **options)
35
+ decorated_attributes.decorate(*names, **options)
36
+ end
37
+
38
+ private
39
+
40
+ def decorated_attributes
41
+ if const_defined?(MODULE_NAME, false)
42
+ const_get(MODULE_NAME)
43
+ else
44
+ const_set(MODULE_NAME, Attributes.new).tap do |mod|
45
+ prepend mod
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ # @api private
52
+ class Attributes < Module
53
+ def initialize(*)
54
+ @names = Set.new
55
+ super
56
+ end
57
+
58
+ def decorate(*names, **options)
59
+ @names += names
60
+
61
+ class_eval do
62
+ names.each do |name|
63
+ define_method name do
64
+ attribute = super()
65
+
66
+ if _render_env && attribute
67
+ _render_env.part(name, attribute, **options)
68
+ else
69
+ attribute
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ def inspect
77
+ %(#<#{self.class.name}#{@names.to_a.sort.inspect}>)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ class View
5
+ # Error raised when critical settings are not configured
6
+ #
7
+ # @api private
8
+ class UndefinedConfigError < StandardError
9
+ def initialize(key)
10
+ super("no +#{key}+ configured")
11
+ end
12
+ end
13
+
14
+ # Error raised when template could not be found within a view's configured
15
+ # paths
16
+ #
17
+ # @api private
18
+ class TemplateNotFoundError < StandardError
19
+ def initialize(template_name, lookup_paths)
20
+ msg = [
21
+ "Template +#{template_name}+ could not be found in paths:",
22
+ lookup_paths.map { |path| " - #{path}" }
23
+ ].join("\n\n")
24
+
25
+ super(msg)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,12 +1,17 @@
1
- require 'dry-equalizer'
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/core/equalizer"
2
4
 
3
5
  module Dry
4
- module View
6
+ class View
7
+ # An exposure defined on a view
8
+ #
9
+ # @api private
5
10
  class Exposure
6
11
  include Dry::Equalizer(:name, :proc, :object, :options)
7
12
 
8
- EXPOSURE_DEPENDENCY_PARAMETER_TYPES = [:req, :opt].freeze
9
- INPUT_PARAMETER_TYPES = [:key, :keyreq, :keyrest].freeze
13
+ EXPOSURE_DEPENDENCY_PARAMETER_TYPES = %i[req opt].freeze
14
+ INPUT_PARAMETER_TYPES = %i[key keyreq keyrest].freeze
10
15
 
11
16
  attr_reader :name
12
17
  attr_reader :proc
@@ -21,7 +26,7 @@ module Dry
21
26
  end
22
27
 
23
28
  def bind(obj)
24
- self.class.new(name, proc, obj, options)
29
+ self.class.new(name, proc, obj, **options)
25
30
  end
26
31
 
27
32
  def dependency_names
@@ -44,6 +49,14 @@ module Dry
44
49
  end
45
50
  end
46
51
 
52
+ def for_layout?
53
+ options.fetch(:layout) { false }
54
+ end
55
+
56
+ def decorate?
57
+ options.fetch(:decorate) { true }
58
+ end
59
+
47
60
  def private?
48
61
  options.fetch(:private) { false }
49
62
  end
@@ -63,23 +76,31 @@ module Dry
63
76
  private
64
77
 
65
78
  def call_proc(input, locals)
66
- args = proc_args(input, locals)
67
-
68
- if proc.is_a?(Method)
69
- proc.(*args)
79
+ args, keywords = proc_args(input, locals)
80
+
81
+ if keywords.empty?
82
+ if proc.is_a?(Method)
83
+ proc.(*args)
84
+ else
85
+ object.instance_exec(*args, &proc)
86
+ end
70
87
  else
71
- object.instance_exec(*args, &proc)
88
+ if proc.is_a?(Method)
89
+ proc.(*args, **keywords)
90
+ else
91
+ object.instance_exec(*args, **keywords, &proc)
92
+ end
72
93
  end
73
94
  end
74
95
 
75
96
  def proc_args(input, locals)
76
97
  dependency_args = proc_dependency_args(locals)
77
- input_args = proc_input_args(input)
98
+ keywords = proc_input_args(input)
78
99
 
79
- if input_args.any?
80
- dependency_args << input_args
100
+ if keywords.empty?
101
+ [dependency_args, {}]
81
102
  else
82
- dependency_args
103
+ [dependency_args, keywords]
83
104
  end
84
105
  end
85
106
 
@@ -1,9 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "tsort"
4
+ require "dry/core/equalizer"
2
5
  require "dry/view/exposure"
3
6
 
4
7
  module Dry
5
- module View
8
+ class View
9
+ # @api private
6
10
  class Exposures
11
+ include Dry::Equalizer(:exposures)
7
12
  include TSort
8
13
 
9
14
  attr_reader :exposures
@@ -25,7 +30,7 @@ module Dry
25
30
  end
26
31
 
27
32
  def add(name, proc = nil, **options)
28
- exposures[name] = Exposure.new(name, proc, options)
33
+ exposures[name] = Exposure.new(name, proc, **options)
29
34
  end
30
35
 
31
36
  def import(name, exposure)
@@ -40,12 +45,19 @@ module Dry
40
45
  self.class.new(bound_exposures)
41
46
  end
42
47
 
43
- def locals(input)
48
+ def call(input)
49
+ # rubocop:disable Style/MultilineBlockChain
44
50
  tsort.each_with_object({}) { |name, memo|
45
- memo[name] = self[name].(input, memo) if exposures.key?(name)
46
- }.each_with_object({}) { |(name, val), memo|
47
- memo[name] = val unless self[name].private?
51
+ next unless (exposure = self[name])
52
+
53
+ value = exposure.(input, memo)
54
+ value = yield(value, exposure) if block_given?
55
+
56
+ memo[name] = value
57
+ }.each_with_object({}) { |(name, value), memo|
58
+ memo[name] = value unless self[name].private?
48
59
  }
60
+ # rubocop:enable Style/MultilineBlockChain
49
61
  end
50
62
 
51
63
  private
data/lib/dry/view/part.rb CHANGED
@@ -1,77 +1,205 @@
1
- require 'dry-equalizer'
2
- require 'dry/view/scope'
3
- require 'dry/view/missing_renderer'
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/core/equalizer"
4
+ require_relative "decorated_attributes"
5
+ require_relative "render_environment_missing"
4
6
 
5
7
  module Dry
6
- module View
8
+ class View
9
+ # Decorates an exposure value and provides a place to encapsulate
10
+ # view-specific behavior alongside your application's domain objects.
11
+ #
12
+ # @abstract Subclass this and provide your own methods adding view-specific
13
+ # behavior. You should not override `#initialize`.
14
+ #
15
+ # @see https://dry-rb.org/gems/dry-view/parts/
16
+ #
17
+ # @api public
7
18
  class Part
19
+ # @api private
8
20
  CONVENIENCE_METHODS = %i[
21
+ format
9
22
  context
10
23
  render
24
+ scope
11
25
  value
12
26
  ].freeze
13
27
 
14
- include Dry::Equalizer(:_name, :_value, :_decorator, :_context, :_renderer)
28
+ include Dry::Equalizer(:_name, :_value, :_render_env)
29
+ include DecoratedAttributes
15
30
 
31
+ # The part's name. This comes from the exposure supplying the value.
32
+ #
33
+ # @return [Symbol] name
34
+ #
35
+ # @api public
16
36
  attr_reader :_name
17
37
 
38
+ # The decorated value. This is the value returned from the exposure.
39
+ #
40
+ # @overload _value
41
+ # Returns the value.
42
+ # @overload value
43
+ # A convenience alias for `_value`. Is available unless the value itself
44
+ # responds to `#value`.
45
+ #
46
+ # @return [Object] value
47
+ #
48
+ # @api public
18
49
  attr_reader :_value
19
50
 
20
- attr_reader :_context
21
-
22
- attr_reader :_renderer
23
-
24
- attr_reader :_decorator
51
+ # The current render environment
52
+ #
53
+ # @return [RenderEnvironment] render environment
54
+ #
55
+ # @api private
56
+ attr_reader :_render_env
25
57
 
26
- attr_reader :_decorated_attributes
58
+ # Determins a part name (when initialized without one). Intended for use
59
+ # only while unit testing Parts.
60
+ #
61
+ # @api private
62
+ def self.part_name(inflector)
63
+ name ? inflector.underscore(inflector.demodulize(name)) : "part"
64
+ end
27
65
 
66
+ # Returns a new Part instance
67
+ #
68
+ # @param name [Symbol] part name
69
+ # @param value [Object] the value to decorate
70
+ # @param render_env [RenderEnvironment] render environment
71
+ #
28
72
  # @api public
29
- def self.decorate(*names, **options)
30
- names.each do |name|
31
- decorated_attributes[name] = options
32
- end
73
+ def initialize(
74
+ render_env: RenderEnvironmentMissing.new,
75
+ name: self.class.part_name(render_env.inflector),
76
+ value:
77
+ )
78
+ @_name = name
79
+ @_value = value
80
+ @_render_env = render_env
33
81
  end
34
82
 
35
- # @api private
36
- def self.decorated_attributes
37
- @decorated_attributes ||= {}
83
+ # The template format for the current render environment.
84
+ #
85
+ # @overload _format
86
+ # Returns the format.
87
+ # @overload format
88
+ # A convenience alias for `#_format.` Is available unless the value
89
+ # itself responds to `#format`.
90
+ #
91
+ # @return [Symbol] format
92
+ #
93
+ # @api public
94
+ def _format
95
+ _render_env.format
38
96
  end
39
97
 
40
- # FIXME: does MissingRenderer.new lead to needless allocations of MissingRenderer? We only need one globally.
41
- def initialize(name:, value:, decorator: Dry::View::Decorator.new, renderer: MissingRenderer.new, context: nil)
42
- @_name = name
43
- @_value = value
44
- @_context = context
45
- @_renderer = renderer
46
- @_decorator = decorator
47
- @_decorated_attributes = {}
98
+ # The context object for the current render environment
99
+ #
100
+ # @overload _context
101
+ # Returns the context.
102
+ # @overload context
103
+ # A convenience alias for `#_context`. Is available unless the value
104
+ # itself responds to `#context`.
105
+ #
106
+ # @return [Context] context
107
+ #
108
+ # @api public
109
+ def _context
110
+ _render_env.context
48
111
  end
49
112
 
113
+ # Renders a new partial with the part included in its locals.
114
+ #
115
+ # @overload _render(partial_name, as: _name, **locals, &block)
116
+ # Renders the partial.
117
+ # @overload render(partial_name, as: _name, **locals, &block)
118
+ # A convenience alias for `#_render`. Is available unless the value
119
+ # itself responds to `#render`.
120
+ #
121
+ # @param partial_name [Symbol, String] partial name
122
+ # @param as [Symbol] the name for the Part to assume in the partial's locals. Defaults to
123
+ # the Part's `_name`.
124
+ # @param locals [Hash<Symbol, Object>] other locals to provide the partial
125
+ #
126
+ # @return [String] rendered partial
127
+ #
128
+ # @api public
129
+ # rubocop:disable Naming/UncommunicativeMethodParamName
50
130
  def _render(partial_name, as: _name, **locals, &block)
51
- _renderer.partial(partial_name, _render_scope(as, locals), &block)
131
+ _render_env.partial(partial_name, _render_env.scope({as => self}.merge(locals)), &block)
132
+ end
133
+ # rubocop:enable Naming/UncommunicativeMethodParamName
134
+
135
+ # Builds a new scope with the part included in its locals.
136
+ #
137
+ # @overload _scope(scope_name = nil, **locals)
138
+ # Builds the scope.
139
+ # @overload scope(scope_name = nil, **locals)
140
+ # A convenience alias for `#_scope`. Is available unless the value
141
+ # itself responds to `#scope`.
142
+ #
143
+ # @param scope_name [Symbol, nil] scope name, used by the scope builder to determine the
144
+ # scope class
145
+ # @param locals [Hash<Symbol, Object>] other locals to provide the partial
146
+ #
147
+ # @return [Dry::View::Scope] scope
148
+ #
149
+ # @api public
150
+ def _scope(scope_name = nil, **locals)
151
+ _render_env.scope(scope_name, {_name => self}.merge(locals))
52
152
  end
53
153
 
154
+ # Returns a string representation of the value
155
+ #
156
+ # @return [String]
157
+ #
158
+ # @api public
54
159
  def to_s
55
160
  _value.to_s
56
161
  end
57
162
 
58
- def new(klass = (self.class), name: (_name), value: (_value), **options)
163
+ # Builds a new a part with the given parameters
164
+ #
165
+ # This is helpful for manually constructing a new part object that
166
+ # maintains the current render environment.
167
+ #
168
+ # However, using `.decorate` is preferred for declaring attributes that
169
+ # should also be decorated as parts.
170
+ #
171
+ # @see DecoratedAttributes::ClassInterface#decorate
172
+ #
173
+ # @param klass [Class] part class to use (defaults to the part's class)
174
+ # @param name [Symbol] part name (defaults to the part's name)
175
+ # @param value [Object] value to decorate (defaults to the part's value)
176
+ # @param options[Hash<Symbol, Object>] other options to provide when initializing the new part
177
+ #
178
+ # @api public
179
+ def new(klass = self.class, name: _name, value: _value, **options)
59
180
  klass.new(
60
181
  name: name,
61
182
  value: value,
62
- context: _context,
63
- renderer: _renderer,
64
- decorator: _decorator,
65
- **options,
183
+ render_env: _render_env,
184
+ **options
66
185
  )
67
186
  end
68
187
 
188
+ # Returns a string representation of the part
189
+ #
190
+ # @return [String]
191
+ #
192
+ # @api public
193
+ def inspect
194
+ %(#<#{self.class.name} name=#{_name.inspect} value=#{_value.inspect}>)
195
+ end
196
+
69
197
  private
70
198
 
199
+ # Handles missing methods. If the `_value` responds to the method, then
200
+ # the method will be sent to the value.
71
201
  def method_missing(name, *args, &block)
72
- if self.class.decorated_attributes.key?(name)
73
- _resolve_decorated_attribute(name)
74
- elsif _value.respond_to?(name)
202
+ if _value.respond_to?(name)
75
203
  _value.public_send(name, *args, &block)
76
204
  elsif CONVENIENCE_METHODS.include?(name)
77
205
  __send__(:"_#{name}", *args, &block)
@@ -79,25 +207,10 @@ module Dry
79
207
  super
80
208
  end
81
209
  end
210
+ ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true)
82
211
 
83
- def _render_scope(name, **locals)
84
- Scope.new(
85
- locals: locals.merge(name => self),
86
- context: _context,
87
- renderer: _renderer,
88
- )
89
- end
90
-
91
- def _resolve_decorated_attribute(name)
92
- _decorated_attributes.fetch(name) {
93
- _decorated_attributes[name] = _decorator.(
94
- name,
95
- _value.__send__(name),
96
- renderer: _renderer,
97
- context: _context,
98
- **self.class.decorated_attributes[name],
99
- )
100
- }
212
+ def respond_to_missing?(name, include_private = false)
213
+ CONVENIENCE_METHODS.include?(name) || _value.respond_to?(name, include_private) || super
101
214
  end
102
215
  end
103
216
  end