view_component 2.17.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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +582 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +1000 -0
  5. data/app/controllers/view_components_controller.rb +71 -0
  6. data/app/views/view_components/index.html.erb +8 -0
  7. data/app/views/view_components/preview.html.erb +1 -0
  8. data/app/views/view_components/previews.html.erb +6 -0
  9. data/lib/rails/generators/component/USAGE +13 -0
  10. data/lib/rails/generators/component/component_generator.rb +42 -0
  11. data/lib/rails/generators/component/templates/component.rb.tt +7 -0
  12. data/lib/rails/generators/erb/component_generator.rb +30 -0
  13. data/lib/rails/generators/erb/templates/component.html.erb.tt +1 -0
  14. data/lib/rails/generators/haml/component_generator.rb +30 -0
  15. data/lib/rails/generators/haml/templates/component.html.haml.tt +1 -0
  16. data/lib/rails/generators/rspec/component_generator.rb +19 -0
  17. data/lib/rails/generators/rspec/templates/component_spec.rb.tt +13 -0
  18. data/lib/rails/generators/slim/component_generator.rb +30 -0
  19. data/lib/rails/generators/slim/templates/component.html.slim.tt +1 -0
  20. data/lib/rails/generators/test_unit/component_generator.rb +20 -0
  21. data/lib/rails/generators/test_unit/templates/component_test.rb.tt +10 -0
  22. data/lib/view_component.rb +14 -0
  23. data/lib/view_component/base.rb +458 -0
  24. data/lib/view_component/collection.rb +41 -0
  25. data/lib/view_component/compile_cache.rb +24 -0
  26. data/lib/view_component/engine.rb +101 -0
  27. data/lib/view_component/preview.rb +111 -0
  28. data/lib/view_component/preview_template_error.rb +6 -0
  29. data/lib/view_component/previewable.rb +38 -0
  30. data/lib/view_component/render_component_helper.rb +9 -0
  31. data/lib/view_component/render_component_to_string_helper.rb +9 -0
  32. data/lib/view_component/render_monkey_patch.rb +13 -0
  33. data/lib/view_component/render_to_string_monkey_patch.rb +13 -0
  34. data/lib/view_component/rendering_component_helper.rb +9 -0
  35. data/lib/view_component/rendering_monkey_patch.rb +13 -0
  36. data/lib/view_component/slot.rb +7 -0
  37. data/lib/view_component/slotable.rb +121 -0
  38. data/lib/view_component/template_error.rb +9 -0
  39. data/lib/view_component/test_case.rb +9 -0
  40. data/lib/view_component/test_helpers.rb +49 -0
  41. data/lib/view_component/version.rb +11 -0
  42. metadata +244 -0
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ class Collection
5
+ def render_in(view_context, &block)
6
+ iterator = ActionView::PartialIteration.new(@collection.size)
7
+
8
+ @component.compile(raise_errors: true)
9
+ @component.validate_collection_parameter!(validate_default: true)
10
+
11
+ @collection.map do |item|
12
+ content = @component.new(**component_options(item, iterator)).render_in(view_context, &block)
13
+ iterator.iterate!
14
+ content
15
+ end.join.html_safe
16
+ end
17
+
18
+ private
19
+
20
+ def initialize(component, object, **options)
21
+ @component = component
22
+ @collection = collection_variable(object || [])
23
+ @options = options
24
+ end
25
+
26
+ def collection_variable(object)
27
+ if object.respond_to?(:to_ary)
28
+ object.to_ary
29
+ else
30
+ raise ArgumentError.new("The value of the argument isn't a valid collection. Make sure it responds to to_ary: #{object.inspect}")
31
+ end
32
+ end
33
+
34
+ def component_options(item, iterator)
35
+ item_options = { @component.collection_parameter => item }
36
+ item_options[@component.collection_counter_parameter] = iterator.index + 1 if @component.counter_argument_present?
37
+
38
+ @options.merge(item_options)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ # Keeps track of which templates have already been compiled
5
+ # This is not part of the public API
6
+ module CompileCache
7
+ mattr_accessor :cache, instance_reader: false, instance_accessor: false do
8
+ Set.new
9
+ end
10
+ module_function
11
+
12
+ def register(klass)
13
+ cache << klass
14
+ end
15
+
16
+ def compiled?(klass)
17
+ cache.include? klass
18
+ end
19
+
20
+ def invalidate!
21
+ cache.clear
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,101 @@
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.view_component = ActiveSupport::OrderedOptions.new
9
+ config.view_component.preview_paths ||= []
10
+
11
+ initializer "view_component.set_configs" do |app|
12
+ options = app.config.view_component
13
+
14
+ options.render_monkey_patch_enabled = true if options.render_monkey_patch_enabled.nil?
15
+ options.show_previews = Rails.env.development? if options.show_previews.nil?
16
+ options.preview_route ||= ViewComponent::Base.preview_route
17
+
18
+ if options.show_previews
19
+ options.preview_paths << "#{Rails.root}/test/components/previews" if defined?(Rails.root)
20
+
21
+ if options.preview_path.present?
22
+ ActiveSupport::Deprecation.warn(
23
+ "`preview_path` will be removed in v3.0.0. Use `preview_paths` instead."
24
+ )
25
+ options.preview_paths << options.preview_path
26
+ end
27
+ end
28
+
29
+ ActiveSupport.on_load(:view_component) do
30
+ options.each { |k, v| send("#{k}=", v) }
31
+ end
32
+ end
33
+
34
+ initializer "view_component.set_autoload_paths" do |app|
35
+ options = app.config.view_component
36
+
37
+ if options.show_previews && options.preview_path
38
+ ActiveSupport::Dependencies.autoload_paths << options.preview_path
39
+ end
40
+ end
41
+
42
+ initializer "view_component.eager_load_actions" do
43
+ ActiveSupport.on_load(:after_initialize) do
44
+ ViewComponent::Base.descendants.each(&:compile)
45
+ end
46
+ end
47
+
48
+ initializer "view_component.compile_config_methods" do
49
+ ActiveSupport.on_load(:view_component) do
50
+ config.compile_methods! if config.respond_to?(:compile_methods!)
51
+ end
52
+ end
53
+
54
+ initializer "view_component.monkey_patch_render" do |app|
55
+ next if Rails.version.to_f >= 6.1 || !app.config.view_component.render_monkey_patch_enabled
56
+
57
+ ActiveSupport.on_load(:action_view) do
58
+ require "view_component/render_monkey_patch"
59
+ ActionView::Base.prepend ViewComponent::RenderMonkeyPatch
60
+ end
61
+
62
+ ActiveSupport.on_load(:action_controller) do
63
+ require "view_component/rendering_monkey_patch"
64
+ require "view_component/render_to_string_monkey_patch"
65
+ ActionController::Base.prepend ViewComponent::RenderingMonkeyPatch
66
+ ActionController::Base.prepend ViewComponent::RenderToStringMonkeyPatch
67
+ end
68
+ end
69
+
70
+ initializer "view_component.include_render_component" do |app|
71
+ next if Rails.version.to_f >= 6.1
72
+
73
+ ActiveSupport.on_load(:action_view) do
74
+ require "view_component/render_component_helper"
75
+ ActionView::Base.include ViewComponent::RenderComponentHelper
76
+ end
77
+
78
+ ActiveSupport.on_load(:action_controller) do
79
+ require "view_component/rendering_component_helper"
80
+ require "view_component/render_component_to_string_helper"
81
+ ActionController::Base.include ViewComponent::RenderingComponentHelper
82
+ ActionController::Base.include ViewComponent::RenderComponentToStringHelper
83
+ end
84
+ end
85
+
86
+ config.after_initialize do |app|
87
+ options = app.config.view_component
88
+
89
+ if options.show_previews
90
+ app.routes.prepend do
91
+ get options.preview_route, to: "view_components#index", as: :preview_view_components, internal: true
92
+ get "#{options.preview_route}/*path", to: "view_components#previews", as: :preview_view_component, internal: true
93
+ end
94
+ end
95
+
96
+ app.executor.to_run :before do
97
+ CompileCache.invalidate! unless ActionView::Base.cache_template_loading
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,111 @@
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
+ {
12
+ args: args,
13
+ block: block,
14
+ component: component,
15
+ locals: {},
16
+ template: "view_components/preview",
17
+ }
18
+ end
19
+
20
+ def render_with_template(template: nil, locals: {})
21
+ {
22
+ template: template,
23
+ locals: locals
24
+ }
25
+ end
26
+
27
+ class << self
28
+ # Returns all component preview classes.
29
+ def all
30
+ load_previews if descendants.empty?
31
+ descendants
32
+ end
33
+
34
+ # Returns the arguments for rendering of the component in its layout
35
+ def render_args(example, params: {})
36
+ example_params_names = instance_method(example).parameters.map(&:last)
37
+ provided_params = params.slice(*example_params_names).to_h.symbolize_keys
38
+ result = provided_params.empty? ? new.public_send(example) : new.public_send(example, **provided_params)
39
+ result ||= {}
40
+ result[:template] = preview_example_template_path(example) if result[:template].nil?
41
+ @layout = nil unless defined?(@layout)
42
+ result.merge(layout: @layout)
43
+ end
44
+
45
+ # Returns the component object class associated to the preview.
46
+ def component
47
+ name.chomp("Preview").constantize
48
+ end
49
+
50
+ # Returns all of the available examples for the component preview.
51
+ def examples
52
+ public_instance_methods(false).map(&:to_s).sort
53
+ end
54
+
55
+ # Returns +true+ if the example of the component preview exists.
56
+ def example_exists?(example)
57
+ examples.include?(example)
58
+ end
59
+
60
+ # Returns +true+ if the preview exists.
61
+ def exists?(preview)
62
+ all.any? { |p| p.preview_name == preview }
63
+ end
64
+
65
+ # Find a component preview by its underscored class name.
66
+ def find(preview)
67
+ all.find { |p| p.preview_name == preview }
68
+ end
69
+
70
+ # Returns the underscored name of the component preview without the suffix.
71
+ def preview_name
72
+ name.chomp("Preview").underscore
73
+ end
74
+
75
+ # Setter for layout name.
76
+ def layout(layout_name)
77
+ @layout = layout_name
78
+ end
79
+
80
+ # Returns the relative path (from preview_path) to the preview example template if the template exists
81
+ def preview_example_template_path(example)
82
+ preview_path = Array(preview_paths).detect do |preview_path|
83
+ Dir["#{preview_path}/#{preview_name}_preview/#{example}.html.*"].first
84
+ end
85
+
86
+ if preview_path.nil?
87
+ raise PreviewTemplateError, "preview template for example #{example} does not exist"
88
+ end
89
+
90
+ path = Dir["#{preview_path}/#{preview_name}_preview/#{example}.html.*"].first
91
+ Pathname.new(path).relative_path_from(Pathname.new(preview_path)).to_s
92
+ end
93
+
94
+ private
95
+
96
+ def load_previews
97
+ Array(preview_paths).each do |preview_path|
98
+ Dir["#{preview_path}/**/*_preview.rb"].sort.each { |file| require_dependency file }
99
+ end
100
+ end
101
+
102
+ def preview_paths
103
+ Base.preview_paths
104
+ end
105
+
106
+ def show_previews
107
+ Base.show_previews
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ class PreviewTemplateError < StandardError
5
+ end
6
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module ViewComponent # :nodoc:
6
+ module Previewable
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ # Set the location of component previews through app configuration:
11
+ #
12
+ # config.view_component.preview_paths << "#{Rails.root}/lib/component_previews"
13
+ #
14
+ mattr_accessor :preview_paths, instance_writer: false
15
+
16
+ # TODO: deprecated, remove in v3.0.0
17
+ mattr_accessor :preview_path, instance_writer: false
18
+
19
+ # Enable or disable component previews through app configuration:
20
+ #
21
+ # config.view_component.show_previews = true
22
+ #
23
+ # Defaults to +true+ for development environment
24
+ #
25
+ mattr_accessor :show_previews, instance_writer: false
26
+
27
+ # Set the entry route for component previews through app configuration:
28
+ #
29
+ # config.view_component.preview_route = "/previews"
30
+ #
31
+ # Defaults to +/rails/view_components+ when `show_previews' is enabled
32
+ #
33
+ mattr_accessor :preview_route, instance_writer: false do
34
+ "/rails/view_components"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module RenderComponentHelper # :nodoc:
5
+ def render_component(component, &block)
6
+ component.render_in(self, &block)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module RenderComponentToStringHelper # :nodoc:
5
+ def render_component_to_string(component)
6
+ component.render_in(self.view_context)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module RenderMonkeyPatch # :nodoc:
5
+ def render(options = {}, args = {}, &block)
6
+ if options.respond_to?(:render_in)
7
+ options.render_in(self, &block)
8
+ else
9
+ super
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module RenderToStringMonkeyPatch # :nodoc:
5
+ def render_to_string(options = {}, args = {})
6
+ if options.respond_to?(:render_in)
7
+ options.render_in(self.view_context)
8
+ else
9
+ super
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module RenderingComponentHelper # :nodoc:
5
+ def render_component(component)
6
+ self.response_body = component.render_in(self.view_context)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module RenderingMonkeyPatch # :nodoc:
5
+ def render(options = {}, args = {})
6
+ if options.respond_to?(:render_in)
7
+ self.response_body = options.render_in(self.view_context)
8
+ else
9
+ super
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ class Slot
5
+ attr_accessor :content
6
+ end
7
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ require "view_component/slot"
6
+
7
+ module ViewComponent
8
+ module Slotable
9
+ extend ActiveSupport::Concern
10
+
11
+ class_methods do
12
+ # support initializing slots as:
13
+ #
14
+ # with_slot(
15
+ # :header,
16
+ # collection: true|false,
17
+ # class_name: "Header" # class name string, used to instantiate Slot
18
+ # )
19
+ def with_slot(*slot_names, collection: false, class_name: nil)
20
+ slot_names.each do |slot_name|
21
+ # Ensure slot_name is not already declared
22
+ if self.slots.key?(slot_name)
23
+ raise ArgumentError.new("#{slot_name} slot declared multiple times")
24
+ end
25
+
26
+ # Ensure slot name is not :content
27
+ if slot_name == :content
28
+ raise ArgumentError.new ":content is a reserved slot name. Please use another name, such as ':body'"
29
+ end
30
+
31
+ # Set the name of the method used to access the Slot(s)
32
+ accessor_name =
33
+ if collection
34
+ # If Slot is a collection, set the accessor
35
+ # name to the pluralized form of the slot name
36
+ # For example: :tab => :tabs
37
+ ActiveSupport::Inflector.pluralize(slot_name)
38
+ else
39
+ slot_name
40
+ end
41
+
42
+ instance_variable_name = "@#{accessor_name}"
43
+
44
+ # If the slot is a collection, define an accesor that defaults to an empty array
45
+ if collection
46
+ class_eval <<-RUBY
47
+ def #{accessor_name}
48
+ #{instance_variable_name} ||= []
49
+ end
50
+ RUBY
51
+ else
52
+ attr_reader accessor_name
53
+ end
54
+
55
+ # Default class_name to ViewComponent::Slot
56
+ class_name = "ViewComponent::Slot" unless class_name.present?
57
+
58
+ # Register the slot on the component
59
+ self.slots[slot_name] = {
60
+ class_name: class_name,
61
+ instance_variable_name: instance_variable_name,
62
+ collection: collection
63
+ }
64
+ end
65
+ end
66
+ end
67
+
68
+ # Build a Slot instance on a component,
69
+ # exposing it for use inside the
70
+ # component template.
71
+ #
72
+ # slot: Name of Slot, in symbol form
73
+ # **args: Arguments to be passed to Slot initializer
74
+ #
75
+ # For example:
76
+ # <%= render(SlotsComponent.new) do |component| %>
77
+ # <% component.slot(:footer, class_names: "footer-class") do %>
78
+ # <p>This is my footer!</p>
79
+ # <% end %>
80
+ # <% end %>
81
+ #
82
+ def slot(slot_name, **args, &block)
83
+ # Raise ArgumentError if `slot` does not exist
84
+ unless slots.keys.include?(slot_name)
85
+ raise ArgumentError.new "Unknown slot '#{slot_name}' - expected one of '#{slots.keys}'"
86
+ end
87
+
88
+ slot = slots[slot_name]
89
+
90
+ # The class name of the Slot, such as Header
91
+ slot_class = self.class.const_get(slot[:class_name])
92
+
93
+ unless slot_class <= ViewComponent::Slot
94
+ raise ArgumentError.new "#{slot[:class_name]} must inherit from ViewComponent::Slot"
95
+ end
96
+
97
+ # Instantiate Slot class, accommodating Slots that don't accept arguments
98
+ slot_instance = args.present? ? slot_class.new(**args) : slot_class.new
99
+
100
+ # Capture block and assign to slot_instance#content
101
+ slot_instance.content = view_context.capture(&block).strip.html_safe if block_given?
102
+
103
+ if slot[:collection]
104
+ # Initialize instance variable as an empty array
105
+ # if slot is a collection and has yet to be initialized
106
+ unless instance_variable_defined?(slot[:instance_variable_name])
107
+ instance_variable_set(slot[:instance_variable_name], [])
108
+ end
109
+
110
+ # Append Slot instance to collection accessor Array
111
+ instance_variable_get(slot[:instance_variable_name]) << slot_instance
112
+ else
113
+ # Assign the Slot instance to the slot accessor
114
+ instance_variable_set(slot[:instance_variable_name], slot_instance)
115
+ end
116
+
117
+ # Return nil, as this method should not output anything to the view itself.
118
+ nil
119
+ end
120
+ end
121
+ end