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.
- checksums.yaml +7 -0
- data/.github/ISSUE_TEMPLATE +27 -0
- data/.github/PULL_REQUEST_TEMPLATE +17 -0
- data/.github/workflows/ruby_on_rails.yml +31 -0
- data/.gitignore +53 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +373 -0
- data/CODE_OF_CONDUCT.md +76 -0
- data/CONTRIBUTING.md +46 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +202 -0
- data/LICENSE.txt +21 -0
- data/README.md +460 -0
- data/Rakefile +12 -0
- data/app/controllers/rails/components_controller.rb +65 -0
- data/docs/case-studies/jellyswitch.md +76 -0
- data/lib/action_view/component.rb +4 -0
- data/lib/action_view/component/base.rb +13 -0
- data/lib/action_view/component/preview.rb +8 -0
- data/lib/action_view/component/railtie.rb +3 -0
- data/lib/action_view/component/test_case.rb +9 -0
- data/lib/action_view/component/test_helpers.rb +17 -0
- data/lib/rails/generators/component/USAGE +13 -0
- data/lib/rails/generators/component/component_generator.rb +44 -0
- data/lib/rails/generators/component/templates/component.rb.tt +5 -0
- data/lib/rails/generators/erb/component_generator.rb +21 -0
- data/lib/rails/generators/erb/templates/component.html.erb.tt +1 -0
- data/lib/rails/generators/haml/component_generator.rb +21 -0
- data/lib/rails/generators/haml/templates/component.html.haml.tt +1 -0
- data/lib/rails/generators/rspec/component_generator.rb +19 -0
- data/lib/rails/generators/rspec/templates/component_spec.rb.tt +13 -0
- data/lib/rails/generators/slim/component_generator.rb +21 -0
- data/lib/rails/generators/slim/templates/component.html.slim.tt +1 -0
- data/lib/rails/generators/test_unit/component_generator.rb +20 -0
- data/lib/rails/generators/test_unit/templates/component_test.rb.tt +10 -0
- data/lib/railties/lib/rails.rb +5 -0
- data/lib/railties/lib/rails/templates/rails/components/index.html.erb +8 -0
- data/lib/railties/lib/rails/templates/rails/components/preview.html.erb +1 -0
- data/lib/railties/lib/rails/templates/rails/components/previews.html.erb +6 -0
- data/lib/view_component.rb +30 -0
- data/lib/view_component/base.rb +279 -0
- data/lib/view_component/conversion.rb +9 -0
- data/lib/view_component/engine.rb +65 -0
- data/lib/view_component/preview.rb +78 -0
- data/lib/view_component/previewable.rb +25 -0
- data/lib/view_component/render_monkey_patch.rb +31 -0
- data/lib/view_component/rendering_monkey_patch.rb +13 -0
- data/lib/view_component/template_error.rb +9 -0
- data/lib/view_component/test_case.rb +9 -0
- data/lib/view_component/test_helpers.rb +39 -0
- data/lib/view_component/version.rb +11 -0
- data/script/bootstrap +6 -0
- data/script/console +8 -0
- data/script/install +6 -0
- data/script/release +6 -0
- data/script/test +6 -0
- data/view_component.gemspec +46 -0
- 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,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,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
|