view_component 1.16.0

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.

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