view_component 1.16.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of view_component might be problematic. Click here for more details.

Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.github/ISSUE_TEMPLATE +27 -0
  3. data/.github/PULL_REQUEST_TEMPLATE +17 -0
  4. data/.github/workflows/ruby_on_rails.yml +31 -0
  5. data/.gitignore +53 -0
  6. data/.rubocop.yml +8 -0
  7. data/CHANGELOG.md +373 -0
  8. data/CODE_OF_CONDUCT.md +76 -0
  9. data/CONTRIBUTING.md +46 -0
  10. data/Gemfile +8 -0
  11. data/Gemfile.lock +202 -0
  12. data/LICENSE.txt +21 -0
  13. data/README.md +460 -0
  14. data/Rakefile +12 -0
  15. data/app/controllers/rails/components_controller.rb +65 -0
  16. data/docs/case-studies/jellyswitch.md +76 -0
  17. data/lib/action_view/component.rb +4 -0
  18. data/lib/action_view/component/base.rb +13 -0
  19. data/lib/action_view/component/preview.rb +8 -0
  20. data/lib/action_view/component/railtie.rb +3 -0
  21. data/lib/action_view/component/test_case.rb +9 -0
  22. data/lib/action_view/component/test_helpers.rb +17 -0
  23. data/lib/rails/generators/component/USAGE +13 -0
  24. data/lib/rails/generators/component/component_generator.rb +44 -0
  25. data/lib/rails/generators/component/templates/component.rb.tt +5 -0
  26. data/lib/rails/generators/erb/component_generator.rb +21 -0
  27. data/lib/rails/generators/erb/templates/component.html.erb.tt +1 -0
  28. data/lib/rails/generators/haml/component_generator.rb +21 -0
  29. data/lib/rails/generators/haml/templates/component.html.haml.tt +1 -0
  30. data/lib/rails/generators/rspec/component_generator.rb +19 -0
  31. data/lib/rails/generators/rspec/templates/component_spec.rb.tt +13 -0
  32. data/lib/rails/generators/slim/component_generator.rb +21 -0
  33. data/lib/rails/generators/slim/templates/component.html.slim.tt +1 -0
  34. data/lib/rails/generators/test_unit/component_generator.rb +20 -0
  35. data/lib/rails/generators/test_unit/templates/component_test.rb.tt +10 -0
  36. data/lib/railties/lib/rails.rb +5 -0
  37. data/lib/railties/lib/rails/templates/rails/components/index.html.erb +8 -0
  38. data/lib/railties/lib/rails/templates/rails/components/preview.html.erb +1 -0
  39. data/lib/railties/lib/rails/templates/rails/components/previews.html.erb +6 -0
  40. data/lib/view_component.rb +30 -0
  41. data/lib/view_component/base.rb +279 -0
  42. data/lib/view_component/conversion.rb +9 -0
  43. data/lib/view_component/engine.rb +65 -0
  44. data/lib/view_component/preview.rb +78 -0
  45. data/lib/view_component/previewable.rb +25 -0
  46. data/lib/view_component/render_monkey_patch.rb +31 -0
  47. data/lib/view_component/rendering_monkey_patch.rb +13 -0
  48. data/lib/view_component/template_error.rb +9 -0
  49. data/lib/view_component/test_case.rb +9 -0
  50. data/lib/view_component/test_helpers.rb +39 -0
  51. data/lib/view_component/version.rb +11 -0
  52. data/script/bootstrap +6 -0
  53. data/script/console +8 -0
  54. data/script/install +6 -0
  55. data/script/release +6 -0
  56. data/script/test +6 -0
  57. data/view_component.gemspec +46 -0
  58. metadata +226 -0
@@ -0,0 +1,8 @@
1
+ <% @previews.each do |preview| %>
2
+ <h3><%= link_to preview.preview_name.titleize, "/rails/components/#{preview.preview_name}" %></h3>
3
+ <ul>
4
+ <% preview.examples.each do |preview_example| %>
5
+ <li><%= link_to preview_example, "/rails/components/#{preview.preview_name}/#{preview_example}" %></li>
6
+ <% end %>
7
+ </ul>
8
+ <% end %>
@@ -0,0 +1 @@
1
+ <%= render(@render_args[:component], **@render_args[:args], &@render_args[:block])%>
@@ -0,0 +1,6 @@
1
+ <h3><%= @preview.preview_name.titleize %></h3>
2
+ <ul>
3
+ <% @preview.examples.each do |example| %>
4
+ <li><%= link_to example, "/rails/components/#{@preview.preview_name}/#{example}" %></li>
5
+ <% end %>
6
+ </ul>
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+ require "action_view"
3
+ require "active_support/dependencies/autoload"
4
+
5
+ module ViewComponent
6
+ extend ActiveSupport::Autoload
7
+
8
+ autoload :Base
9
+ autoload :Conversion
10
+ autoload :Preview
11
+ autoload :Previewable
12
+ autoload :TestHelpers
13
+ autoload :TestCase
14
+ autoload :RenderMonkeyPatch
15
+ autoload :RenderingMonkeyPatch
16
+ autoload :TemplateError
17
+ end
18
+
19
+ module ActionView
20
+ module Component
21
+ extend ActiveSupport::Autoload
22
+
23
+ autoload :Base
24
+ autoload :Preview
25
+ autoload :TestCase
26
+ autoload :TestHelpers
27
+ end
28
+ end
29
+
30
+ ActiveModel::Conversion.include ViewComponent::Conversion
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view"
4
+ require "active_support/configurable"
5
+ require "view_component/previewable"
6
+
7
+ module ViewComponent
8
+ class Base < ActionView::Base
9
+ include ActiveSupport::Configurable
10
+ include ViewComponent::Previewable
11
+
12
+ delegate :form_authenticity_token, :protect_against_forgery?, to: :helpers
13
+
14
+ class_attribute :content_areas, default: []
15
+ self.content_areas = [] # default doesn't work until Rails 5.2
16
+
17
+ # Entrypoint for rendering components.
18
+ #
19
+ # view_context: ActionView context from calling view
20
+ # block: optional block to be captured within the view context
21
+ #
22
+ # returns HTML that has been escaped by the respective template handler
23
+ #
24
+ # Example subclass:
25
+ #
26
+ # app/components/my_component.rb:
27
+ # class MyComponent < ViewComponent::Base
28
+ # def initialize(title:)
29
+ # @title = title
30
+ # end
31
+ # end
32
+ #
33
+ # app/components/my_component.html.erb
34
+ # <span title="<%= @title %>">Hello, <%= content %>!</span>
35
+ #
36
+ # In use:
37
+ # <%= render MyComponent.new(title: "greeting") do %>world<% end %>
38
+ # returns:
39
+ # <span title="greeting">Hello, world!</span>
40
+ #
41
+ def render_in(view_context, &block)
42
+ self.class.compile!
43
+ @view_context = view_context
44
+ @view_renderer ||= view_context.view_renderer
45
+ @lookup_context ||= view_context.lookup_context
46
+ @view_flow ||= view_context.view_flow
47
+ @virtual_path ||= virtual_path
48
+ @variant = @lookup_context.variants.first
49
+
50
+ old_current_template = @current_template
51
+ @current_template = self
52
+
53
+ @content = view_context.capture(self, &block) if block_given?
54
+
55
+ before_render_check
56
+
57
+ if render?
58
+ send(self.class.call_method_name(@variant))
59
+ else
60
+ ""
61
+ end
62
+ ensure
63
+ @current_template = old_current_template
64
+ end
65
+
66
+ def before_render_check
67
+ # noop
68
+ end
69
+
70
+ def render?
71
+ true
72
+ end
73
+
74
+ def initialize(*); end
75
+
76
+ def render(options = {}, args = {}, &block)
77
+ if options.is_a?(String) || (options.is_a?(Hash) && options.has_key?(:partial))
78
+ view_context.render(options, args, &block)
79
+ else
80
+ super
81
+ end
82
+ end
83
+
84
+ def controller
85
+ @controller ||= view_context.controller
86
+ end
87
+
88
+ # Provides a proxy to access helper methods through
89
+ def helpers
90
+ @helpers ||= view_context
91
+ end
92
+
93
+ # Removes the first part of the path and the extension.
94
+ def virtual_path
95
+ self.class.source_location.gsub(%r{(.*app/components)|(\.rb)}, "")
96
+ end
97
+
98
+ def view_cache_dependencies
99
+ []
100
+ end
101
+
102
+ def format # :nodoc:
103
+ @variant
104
+ end
105
+
106
+ def with(area, content = nil, &block)
107
+ unless content_areas.include?(area)
108
+ raise ArgumentError.new "Unknown content_area '#{area}' - expected one of '#{content_areas}'"
109
+ end
110
+
111
+ if block_given?
112
+ content = view_context.capture(&block)
113
+ end
114
+
115
+ instance_variable_set("@#{area}".to_sym, content)
116
+ nil
117
+ end
118
+
119
+ private
120
+
121
+ def request
122
+ @request ||= controller.request
123
+ end
124
+
125
+ attr_reader :content, :view_context
126
+
127
+ # The controller used for testing components.
128
+ # Defaults to ApplicationController. This should be set early
129
+ # in the initialization process and should be set to a string.
130
+ mattr_accessor :test_controller
131
+ @@test_controller = "ApplicationController"
132
+
133
+ class << self
134
+ def inherited(child)
135
+ if defined?(Rails)
136
+ child.include Rails.application.routes.url_helpers unless child < Rails.application.routes.url_helpers
137
+ end
138
+
139
+ super
140
+ end
141
+
142
+ def call_method_name(variant)
143
+ if variant.present? && variants.include?(variant)
144
+ "call_#{variant}"
145
+ else
146
+ "call"
147
+ end
148
+ end
149
+
150
+ def source_location
151
+ @source_location ||=
152
+ begin
153
+ # Require `#initialize` to be defined so that we can use `method#source_location`
154
+ # to look up the filename of the component.
155
+ initialize_method = instance_method(:initialize)
156
+ initialize_method.source_location[0] if initialize_method.owner == self
157
+ end
158
+ end
159
+
160
+ def compiled?
161
+ @compiled && ActionView::Base.cache_template_loading
162
+ end
163
+
164
+ def inlined?
165
+ instance_methods(false).grep(/^call/).present? && templates.empty?
166
+ end
167
+
168
+ def compile!
169
+ compile(raise_template_errors: true)
170
+ end
171
+
172
+ # Compile templates to instance methods, assuming they haven't been compiled already.
173
+ # We could in theory do this on app boot, at least in production environments.
174
+ # Right now this just compiles the first time the component is rendered.
175
+ def compile(raise_template_errors: false)
176
+ return if compiled? || inlined?
177
+
178
+ if template_errors.present?
179
+ raise ViewComponent::TemplateError.new(template_errors) if raise_template_errors
180
+ return false
181
+ end
182
+
183
+ templates.each do |template|
184
+ class_eval <<-RUBY, template[:path], -1
185
+ def #{call_method_name(template[:variant])}
186
+ @output_buffer = ActionView::OutputBuffer.new
187
+ #{compiled_template(template[:path])}
188
+ end
189
+ RUBY
190
+ end
191
+
192
+ @compiled = true
193
+ end
194
+
195
+ def variants
196
+ templates.map { |template| template[:variant] }
197
+ end
198
+
199
+ # we'll eventually want to update this to support other types
200
+ def type
201
+ "text/html"
202
+ end
203
+
204
+ def identifier
205
+ source_location
206
+ end
207
+
208
+ def with_content_areas(*areas)
209
+ if areas.include?(:content)
210
+ raise ArgumentError.new ":content is a reserved content area name. Please use another name, such as ':body'"
211
+ end
212
+ attr_reader *areas
213
+ self.content_areas = areas
214
+ end
215
+
216
+ private
217
+
218
+ def matching_views_in_source_location
219
+ return [] unless source_location
220
+ (Dir["#{source_location.chomp(File.extname(source_location))}.*{#{ActionView::Template.template_handler_extensions.join(',')}}"] - [source_location])
221
+ end
222
+
223
+ def templates
224
+ @templates ||=
225
+ matching_views_in_source_location.each_with_object([]) do |path, memo|
226
+ pieces = File.basename(path).split(".")
227
+
228
+ memo << {
229
+ path: path,
230
+ variant: pieces.second.split("+").second&.to_sym,
231
+ handler: pieces.last
232
+ }
233
+ end
234
+ end
235
+
236
+ def template_errors
237
+ @template_errors ||=
238
+ begin
239
+ errors = []
240
+ if source_location.nil?
241
+ # Require `#initialize` to be defined so that we can use `method#source_location`
242
+ # to look up the filename of the component.
243
+ errors << "#{self} must implement #initialize."
244
+ end
245
+
246
+ errors << "Could not find a template file for #{self}." if templates.empty?
247
+
248
+ if templates.count { |template| template[:variant].nil? } > 1
249
+ errors << "More than one template found for #{self}. There can only be one default template file per component."
250
+ end
251
+
252
+ invalid_variants = templates
253
+ .group_by { |template| template[:variant] }
254
+ .map { |variant, grouped| variant if grouped.length > 1 }
255
+ .compact
256
+ .sort
257
+
258
+ unless invalid_variants.empty?
259
+ errors << "More than one template found for #{'variant'.pluralize(invalid_variants.count)} #{invalid_variants.map { |v| "'#{v}'" }.to_sentence} in #{self}. There can only be one template file per variant."
260
+ end
261
+ errors
262
+ end
263
+ end
264
+
265
+ def compiled_template(file_path)
266
+ handler = ActionView::Template.handler_for_extension(File.extname(file_path).gsub(".", ""))
267
+ template = File.read(file_path)
268
+
269
+ if handler.method(:call).parameters.length > 1
270
+ handler.call(self, template)
271
+ else # remove before upstreaming into Rails
272
+ handler.call(OpenStruct.new(source: template, identifier: identifier, type: type))
273
+ end
274
+ end
275
+ end
276
+
277
+ ActiveSupport.run_load_hooks(:action_view_component, self)
278
+ end
279
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent # :nodoc:
4
+ module Conversion
5
+ def to_component_class
6
+ "#{self.class.name}Component".safe_constantize
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "view_component"
5
+
6
+ module ViewComponent
7
+ class Engine < Rails::Engine # :nodoc:
8
+ config.action_view_component = ActiveSupport::OrderedOptions.new
9
+
10
+ initializer "action_view_component.set_configs" do |app|
11
+ options = app.config.action_view_component
12
+
13
+ options.show_previews = Rails.env.development? if options.show_previews.nil?
14
+
15
+ if options.show_previews
16
+ options.preview_path ||= defined?(Rails.root) ? "#{Rails.root}/test/components/previews" : nil
17
+ end
18
+
19
+ ActiveSupport.on_load(:action_view_component) do
20
+ options.each { |k, v| send("#{k}=", v) }
21
+ end
22
+ end
23
+
24
+ initializer "action_view_component.set_autoload_paths" do |app|
25
+ options = app.config.action_view_component
26
+
27
+ if options.show_previews && options.preview_path
28
+ ActiveSupport::Dependencies.autoload_paths << options.preview_path
29
+ end
30
+ end
31
+
32
+ initializer "action_view_component.eager_load_actions" do
33
+ ActiveSupport.on_load(:after_initialize) do
34
+ ViewComponent::Base.descendants.each(&:compile)
35
+ end
36
+ end
37
+
38
+ initializer "action_view_component.compile_config_methods" do
39
+ ActiveSupport.on_load(:action_view_component) do
40
+ config.compile_methods! if config.respond_to?(:compile_methods!)
41
+ end
42
+ end
43
+
44
+ initializer "action_view_component.monkey_patch_render" do
45
+ ActiveSupport.on_load(:action_view) do
46
+ ActionView::Base.prepend ViewComponent::RenderMonkeyPatch
47
+ end
48
+
49
+ ActiveSupport.on_load(:action_controller) do
50
+ ActionController::Base.prepend ViewComponent::RenderingMonkeyPatch
51
+ end
52
+ end
53
+
54
+ config.after_initialize do |app|
55
+ options = app.config.action_view_component
56
+
57
+ if options.show_previews
58
+ app.routes.prepend do
59
+ get "/rails/components" => "rails/components#index", :internal => true
60
+ get "/rails/components/*path" => "rails/components#previews", :internal => true
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/descendants_tracker"
4
+
5
+ module ViewComponent # :nodoc:
6
+ class Preview
7
+ include ActionView::Helpers::TagHelper
8
+ extend ActiveSupport::DescendantsTracker
9
+
10
+ def render(component, **args, &block)
11
+ { component: component, args: args, block: block }
12
+ end
13
+
14
+ class << self
15
+ # Returns all component preview classes.
16
+ def all
17
+ load_previews if descendants.empty?
18
+ descendants
19
+ end
20
+
21
+ # Returns the arguments for rendering of the component in its layout
22
+ def render_args(example)
23
+ new.public_send(example).merge(layout: @layout)
24
+ end
25
+
26
+ # Returns the component object class associated to the preview.
27
+ def component
28
+ name.chomp("Preview").constantize
29
+ end
30
+
31
+ # Returns all of the available examples for the component preview.
32
+ def examples
33
+ public_instance_methods(false).map(&:to_s).sort
34
+ end
35
+
36
+ # Returns +true+ if the example of the component preview exists.
37
+ def example_exists?(example)
38
+ examples.include?(example)
39
+ end
40
+
41
+ # Returns +true+ if the preview exists.
42
+ def exists?(preview)
43
+ all.any? { |p| p.preview_name == preview }
44
+ end
45
+
46
+ # Find a component preview by its underscored class name.
47
+ def find(preview)
48
+ all.find { |p| p.preview_name == preview }
49
+ end
50
+
51
+ # Returns the underscored name of the component preview without the suffix.
52
+ def preview_name
53
+ name.chomp("Preview").underscore
54
+ end
55
+
56
+ # Setter for layout name.
57
+ def layout(layout_name)
58
+ @layout = layout_name
59
+ end
60
+
61
+ private
62
+
63
+ def load_previews
64
+ if preview_path
65
+ Dir["#{preview_path}/**/*_preview.rb"].sort.each { |file| require_dependency file }
66
+ end
67
+ end
68
+
69
+ def preview_path
70
+ Base.preview_path
71
+ end
72
+
73
+ def show_previews
74
+ Base.show_previews
75
+ end
76
+ end
77
+ end
78
+ end