diff --git a/Gemfile b/Gemfile
index e4028ffb43..4cb0507415 100644
--- a/Gemfile
+++ b/Gemfile
@@ -11,7 +11,7 @@ gem "rails", (rails_version == "main") ? {git: "https://github.com/rails/rails",
gem "rspec-rails", "~> 5"
group :test do
- gem "cuprite", "~> 0.8"
+ gem "cuprite", "~> 0.15"
gem "puma", "~> 6"
gem "selenium-webdriver", "4.9.0" # 4.9.1 requires Ruby 3+
diff --git a/Gemfile.lock b/Gemfile.lock
index c5b1fa3608..93a216c4d0 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -74,7 +74,7 @@ GEM
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
- addressable (2.8.4)
+ addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0)
ansi (1.5.0)
appraisal (2.5.0)
@@ -104,9 +104,9 @@ GEM
coderay (1.1.3)
concurrent-ruby (1.2.2)
crass (1.0.6)
- cuprite (0.14.3)
+ cuprite (0.15)
capybara (~> 3.0)
- ferrum (~> 0.13.0)
+ ferrum (~> 0.14.0)
date (3.3.3)
debug (1.8.0)
irb (>= 1.5.0)
@@ -121,7 +121,7 @@ GEM
rubocop
smart_properties
erubi (1.12.0)
- ferrum (0.13)
+ ferrum (0.14)
addressable (~> 2.5)
concurrent-ruby (~> 1.1)
webrick (~> 1.7)
@@ -158,9 +158,9 @@ GEM
matrix (0.4.2)
method_source (1.0.0)
mini_mime (1.1.5)
- mini_portile2 (2.8.4)
+ mini_portile2 (2.8.5)
minitest (5.20.0)
- net-imap (0.4.2)
+ net-imap (0.4.4)
date
net-protocol
net-pop (0.1.2)
@@ -180,10 +180,10 @@ GEM
pry (0.14.2)
coderay (~> 1.1)
method_source (~> 1.0)
- public_suffix (5.0.1)
+ public_suffix (5.0.3)
puma (6.4.0)
nio4r (~> 2.0)
- racc (1.7.1)
+ racc (1.7.3)
rack (2.2.8)
rack-test (2.1.0)
rack (>= 1.3)
@@ -326,13 +326,16 @@ DEPENDENCIES
better_html
bundler (~> 2)
capybara (~> 3)
- cuprite (~> 0.8)
+ cuprite (~> 0.15)
debug
erb_lint
haml (~> 6)
jbuilder (~> 2)
m (~> 1)
minitest (~> 5.18)
+ net-imap
+ net-pop
+ net-smtp
pry (~> 0.13)
puma (~> 6)
rails (~> 7.0.0)
diff --git a/app/helpers/preview_helper.rb b/app/helpers/preview_helper.rb
index 0485b47751..222334985e 100644
--- a/app/helpers/preview_helper.rb
+++ b/app/helpers/preview_helper.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
module PreviewHelper
+ include ActionView::Helpers::AssetUrlHelper if Rails.version.to_f < 6.1
+
AVAILABLE_PRISM_LANGUAGES = %w[ruby erb haml]
FALLBACK_LANGUAGE = "ruby"
@@ -10,6 +12,14 @@ def preview_source
render "preview_source"
end
+ def prism_css_source_url
+ serve_static_preview_assets? ? asset_path("prism.css", skip_pipeline: true) : "https://cdn.jsdelivr.net/npm/prismjs@1.28.0/themes/prism.min.css"
+ end
+
+ def prism_js_source_url
+ serve_static_preview_assets? ? asset_path("prism.min.js", skip_pipeline: true) : "https://cdn.jsdelivr.net/npm/prismjs@1.28.0/prism.min.js"
+ end
+
def find_template_data(lookup_context:, template_identifier:)
template = lookup_context.find_template(template_identifier)
@@ -62,4 +72,8 @@ def prism_language_name_by_template_path(template_file_path:)
language
end
+
+ def serve_static_preview_assets?
+ ViewComponent::Base.config.show_previews && Rails.application.config.public_file_server.enabled
+ end
end
diff --git a/app/views/view_components/_preview_source.html.erb b/app/views/view_components/_preview_source.html.erb
index f1259f291e..606b8ac80a 100644
--- a/app/views/view_components/_preview_source.html.erb
+++ b/app/views/view_components/_preview_source.html.erb
@@ -1,4 +1,4 @@
-
+
Source:
@@ -14,4 +14,4 @@
<% end %>
-
+
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index a263e42671..cb054c9bb6 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -10,6 +10,27 @@ nav_order: 5
## main
+* Replace usage of `String#ends_with?` with `String#end_with?` to reduce the dependency on ActiveSupport core extensions.
+
+ *halo*
+
+* Don't add ActionDispatch::Static middleware unless `public_file_server.enabled`.
+
+ *Daniel Gonzalez*
+ *Reegan Viljoen*
+
+* Resolve an issue where slots starting with `call` would cause a `NameError`
+
+ *Blake Williams*
+
+* Add `use_helper` API.
+
+ *Reegan Viljoen*
+
+* Fix bug where the `Rails` module wasn't being searched from the root namespace.
+
+ *Zenéixe*
+
* Add configuration and support for compiling templates with `frozen_string_literal` magic comment.
*Mitchell Henke*
@@ -18,6 +39,10 @@ nav_order: 5
*Nachiket Pusalkar*
+* Allow setting method when using the `with_request_url` test helper.
+
+ *Andrew Duthie*
+
## 3.7.0
* Support Rails 7.1 in CI.
diff --git a/docs/guide/helpers.md b/docs/guide/helpers.md
index 1407fe14a8..172c3aea08 100644
--- a/docs/guide/helpers.md
+++ b/docs/guide/helpers.md
@@ -51,6 +51,26 @@ class UserComponent < ViewComponent::Base
end
```
+## UseHelpers setter
+
+By default, ViewComponents don't have access to helper methods defined externally. The `use_helpers` method allows external helpers to be called from the component.
+
+To use the `use_helpers` method, include `ViewComponent::UseHelpers`.
+`UseHelpers` defines the helper on the component and is similar in use to using `delegate` on helpers.
+
+```ruby
+class UseHelpersComponent < ViewComponent::Base
+ include ViewComponent::UseHelpers
+ use_helpers :icon
+
+ erb_template <<-ERB
+
+ <%= icon :user %>
+
+ ERB
+end
+```
+
## Nested URL helpers
Rails nested URL helpers implicitly depend on the current `request` in certain cases. Since ViewComponent is built to enable reusing components in different contexts, nested URL helpers should be passed their options explicitly:
diff --git a/docs/index.md b/docs/index.md
index 393772ac4d..24f750a5c6 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -204,6 +204,7 @@ ViewComponent is built by over a hundred members of the community, including:
+
@@ -211,6 +212,7 @@ ViewComponent is built by over a hundred members of the community, including:
+
## Who uses ViewComponent?
diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb
index 1b5d74c601..fc33619e6d 100644
--- a/lib/view_component/base.rb
+++ b/lib/view_component/base.rb
@@ -12,6 +12,7 @@
require "view_component/slotable"
require "view_component/translatable"
require "view_component/with_content_helper"
+require "view_component/use_helpers"
module ViewComponent
class Base < ActionView::Base
@@ -219,7 +220,7 @@ def helpers
@__vc_helpers ||= __vc_original_view_context || controller.view_context
end
- if Rails.env.development? || Rails.env.test?
+ if ::Rails.env.development? || ::Rails.env.test?
def method_missing(method_name, *args) # rubocop:disable Style/MissingRespondToMissing
super
rescue => e # rubocop:disable Style/RescueStandardError
diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb
index 23d9b2ce04..1cc258dffc 100644
--- a/lib/view_component/compiler.rb
+++ b/lib/view_component/compiler.rb
@@ -229,12 +229,12 @@ def inline_calls
component_class.included_modules
)
- view_component_ancestors.flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call/) }.uniq
+ view_component_ancestors.flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call(_|$)/) }.uniq
end
end
def inline_calls_defined_on_self
- @inline_calls_defined_on_self ||= component_class.instance_methods(false).grep(/^call/)
+ @inline_calls_defined_on_self ||= component_class.instance_methods(false).grep(/^call(_|$)/)
end
def variants
diff --git a/lib/view_component/engine.rb b/lib/view_component/engine.rb
index f2943ae719..7d33ec5cce 100644
--- a/lib/view_component/engine.rb
+++ b/lib/view_component/engine.rb
@@ -113,11 +113,15 @@ class Engine < Rails::Engine # :nodoc:
end
initializer "static assets" do |app|
- if app.config.view_component.show_previews
+ if serve_static_preview_assets?(app.config)
app.middleware.use(::ActionDispatch::Static, "#{root}/app/assets/vendor")
end
end
+ def serve_static_preview_assets?(app_config)
+ app_config.view_component.show_previews && app_config.public_file_server.enabled
+ end
+
initializer "compiler mode" do |_app|
ViewComponent::Compiler.mode = if Rails.env.development? || Rails.env.test?
ViewComponent::Compiler::DEVELOPMENT_MODE
diff --git a/lib/view_component/errors.rb b/lib/view_component/errors.rb
index 36f9ee570c..b896f53d1c 100644
--- a/lib/view_component/errors.rb
+++ b/lib/view_component/errors.rb
@@ -104,7 +104,10 @@ class InvalidSlotDefinitionError < BaseError
"string, or callable (that is proc, lambda, etc)"
end
- class SlotPredicateNameError < StandardError
+ class InvalidSlotNameError < StandardError
+ end
+
+ class SlotPredicateNameError < InvalidSlotNameError
MESSAGE =
"COMPONENT declares a slot named SLOT_NAME, which ends with a question mark.\n\n" \
"This isn't allowed because the ViewComponent framework already provides predicate " \
@@ -126,7 +129,7 @@ def initialize(klass_name, slot_name)
end
end
- class ReservedSingularSlotNameError < StandardError
+ class ReservedSingularSlotNameError < InvalidSlotNameError
MESSAGE =
"COMPONENT declares a slot named SLOT_NAME, which is a reserved word in the ViewComponent framework.\n\n" \
"To fix this issue, choose a different name."
@@ -136,7 +139,7 @@ def initialize(klass_name, slot_name)
end
end
- class ReservedPluralSlotNameError < StandardError
+ class ReservedPluralSlotNameError < InvalidSlotNameError
MESSAGE =
"COMPONENT declares a slot named SLOT_NAME, which is a reserved word in the ViewComponent framework.\n\n" \
"To fix this issue, choose a different name."
diff --git a/lib/view_component/slotable.rb b/lib/view_component/slotable.rb
index ecb4826a20..b38ab3100f 100644
--- a/lib/view_component/slotable.rb
+++ b/lib/view_component/slotable.rb
@@ -295,6 +295,7 @@ def validate_plural_slot_name(slot_name)
raise ReservedPluralSlotNameError.new(name, slot_name)
end
+ raise_if_slot_conflicts_with_call(slot_name)
raise_if_slot_ends_with_question_mark(slot_name)
raise_if_slot_registered(slot_name)
end
@@ -308,6 +309,7 @@ def validate_singular_slot_name(slot_name)
raise ReservedSingularSlotNameError.new(name, slot_name)
end
+ raise_if_slot_conflicts_with_call(slot_name)
raise_if_slot_ends_with_question_mark(slot_name)
raise_if_slot_registered(slot_name)
end
@@ -320,7 +322,13 @@ def raise_if_slot_registered(slot_name)
end
def raise_if_slot_ends_with_question_mark(slot_name)
- raise SlotPredicateNameError.new(name, slot_name) if slot_name.to_s.ends_with?("?")
+ raise SlotPredicateNameError.new(name, slot_name) if slot_name.to_s.end_with?("?")
+ end
+
+ def raise_if_slot_conflicts_with_call(slot_name)
+ if slot_name.start_with?("call_")
+ raise InvalidSlotNameError, "Slot cannot start with 'call_'. Please rename #{slot_name}"
+ end
end
end
diff --git a/lib/view_component/test_helpers.rb b/lib/view_component/test_helpers.rb
index 3a24b1aa31..02d1a9b7c4 100644
--- a/lib/view_component/test_helpers.rb
+++ b/lib/view_component/test_helpers.rb
@@ -163,10 +163,20 @@ def with_controller_class(klass)
# end
# ```
#
+ # To specify a request method, pass the method param:
+ #
+ # ```ruby
+ # with_request_url("/users/42", method: "POST") do
+ # render_inline(MyComponent.new)
+ # end
+ # ```
+ #
# @param path [String] The path to set for the current request.
# @param host [String] The host to set for the current request.
- def with_request_url(full_path, host: nil)
+ # @param method [String] The request method to set for the current request.
+ def with_request_url(full_path, host: nil, method: nil)
old_request_host = vc_test_request.host
+ old_request_method = vc_test_request.request_method
old_request_path_info = vc_test_request.path_info
old_request_path_parameters = vc_test_request.path_parameters
old_request_query_parameters = vc_test_request.query_parameters
@@ -177,6 +187,7 @@ def with_request_url(full_path, host: nil)
vc_test_request.instance_variable_set(:@fullpath, full_path)
vc_test_request.instance_variable_set(:@original_fullpath, full_path)
vc_test_request.host = host if host
+ vc_test_request.request_method = method if method
vc_test_request.path_info = path
vc_test_request.path_parameters = Rails.application.routes.recognize_path_with_request(vc_test_request, path, {})
vc_test_request.set_header("action_dispatch.request.query_parameters",
@@ -185,6 +196,7 @@ def with_request_url(full_path, host: nil)
yield
ensure
vc_test_request.host = old_request_host
+ vc_test_request.request_method = old_request_method
vc_test_request.path_info = old_request_path_info
vc_test_request.path_parameters = old_request_path_parameters
vc_test_request.set_header("action_dispatch.request.query_parameters", old_request_query_parameters)
diff --git a/lib/view_component/use_helpers.rb b/lib/view_component/use_helpers.rb
new file mode 100644
index 0000000000..32d13cd433
--- /dev/null
+++ b/lib/view_component/use_helpers.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module ViewComponent::UseHelpers
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def use_helpers(*args)
+ args.each do |helper_method|
+ class_eval(<<-RUBY, __FILE__, __LINE__ + 1)
+ def #{helper_method}(*args, &block)
+ raise HelpersCalledBeforeRenderError if view_context.nil?
+ __vc_original_view_context.#{helper_method}(*args, &block)
+ end
+ RUBY
+
+ ruby2_keywords(helper_method) if respond_to?(:ruby2_keywords, true)
+ end
+ end
+ end
+end
diff --git a/test/sandbox/app/components/current_page_component.rb b/test/sandbox/app/components/current_page_component.rb
index 559862199d..afd1c327e7 100644
--- a/test/sandbox/app/components/current_page_component.rb
+++ b/test/sandbox/app/components/current_page_component.rb
@@ -2,10 +2,6 @@
class CurrentPageComponent < ViewComponent::Base
def text
- if current_page?("/slots")
- "Inside /slots"
- else
- "Outside /slots"
- end
+ "#{current_page?("/slots") ? "Inside" : "Outside"} /slots (#{request.method} #{request.path})"
end
end
diff --git a/test/sandbox/app/components/use_helpers_component.html.erb b/test/sandbox/app/components/use_helpers_component.html.erb
new file mode 100644
index 0000000000..f24542007a
--- /dev/null
+++ b/test/sandbox/app/components/use_helpers_component.html.erb
@@ -0,0 +1,3 @@
+
+ <%= message %>
+
diff --git a/test/sandbox/app/components/use_helpers_component.rb b/test/sandbox/app/components/use_helpers_component.rb
new file mode 100644
index 0000000000..375c07db63
--- /dev/null
+++ b/test/sandbox/app/components/use_helpers_component.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class UseHelpersComponent < ViewComponent::Base
+ include ViewComponent::UseHelpers
+
+ use_helpers :message
+end
diff --git a/test/sandbox/config/routes.rb b/test/sandbox/config/routes.rb
index e921c0003d..afbb357be9 100644
--- a/test/sandbox/config/routes.rb
+++ b/test/sandbox/config/routes.rb
@@ -29,6 +29,7 @@
get :cached_partial, to: "integration_examples#cached_partial"
get :inherited_sidecar, to: "integration_examples#inherited_sidecar"
get :inherited_from_uncompilable_component, to: "integration_examples#inherited_from_uncompilable_component"
+ post :create, to: "integration_examples#create"
constraints(lambda { |request| request.env["warden"].authenticate! }) do
get :constraints_with_env, to: "integration_examples#index"
diff --git a/test/sandbox/test/preview_helper_test.rb b/test/sandbox/test/preview_helper_test.rb
index 108a8a71dd..5921a525d1 100644
--- a/test/sandbox/test/preview_helper_test.rb
+++ b/test/sandbox/test/preview_helper_test.rb
@@ -155,5 +155,37 @@ def test_raises_with_conflict_in_template_resolution
assert_equal("Found multiple templates for #{template_identifier}.", exception.message)
end
end
+
+ def test_prism_css_source_url_with_asset_pipeline
+ Rails.application.config.public_file_server.stub(:enabled, true) do
+ if Rails.version.to_f >= 6.1
+ assert_equal "/assets/prism.css", PreviewHelper.prism_css_source_url
+ else
+ assert_equal "/prism.css", PreviewHelper.prism_css_source_url
+ end
+ end
+ end
+
+ def test_prism_css_source_url_with_no_asset_pipeline
+ Rails.application.config.public_file_server.stub(:enabled, false) do
+ assert_equal "https://cdn.jsdelivr.net/npm/prismjs@1.28.0/themes/prism.min.css", PreviewHelper.prism_css_source_url
+ end
+ end
+
+ def test_prism_js_source_with_asset_pipeline
+ Rails.application.config.public_file_server.stub(:enabled, true) do
+ if Rails.version.to_f >= 6.1
+ assert_equal "/assets/prism.min.js", PreviewHelper.prism_js_source_url
+ else
+ assert_equal "/prism.min.js", PreviewHelper.prism_js_source_url
+ end
+ end
+ end
+
+ def test_prism_js_source_url_with_no_asset_pipeline
+ Rails.application.config.public_file_server.stub(:enabled, false) do
+ assert_equal "https://cdn.jsdelivr.net/npm/prismjs@1.28.0/prism.min.js", PreviewHelper.prism_js_source_url
+ end
+ end
end
end
diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb
index 9e3de45bb2..95ebceee91 100644
--- a/test/sandbox/test/rendering_test.rb
+++ b/test/sandbox/test/rendering_test.rb
@@ -1095,6 +1095,11 @@ def test_content_security_policy_nonce
assert_selector("script", text: "\n//\n", visible: :hidden)
end
+ def test_use_helper
+ render_inline(UseHelpersComponent.new)
+ assert_selector ".helper__message", text: "Hello helper method"
+ end
+
def test_frozen_string_literal_disabled
old_value = ViewComponent::Base.config.frozen_string_literal
ViewComponent::Base.config.frozen_string_literal = false
diff --git a/test/sandbox/test/slotable_test.rb b/test/sandbox/test/slotable_test.rb
index 3a931167e6..086f418e59 100644
--- a/test/sandbox/test/slotable_test.rb
+++ b/test/sandbox/test/slotable_test.rb
@@ -719,4 +719,26 @@ def test_slot_with_content_shorthand
assert component.title.content?
end
+
+ def test_slot_names_cannot_start_with_call_
+ assert_raises ViewComponent::InvalidSlotNameError do
+ Class.new(ViewComponent::Base) do
+ renders_one :call_out_title
+ end
+ end
+
+ assert_raises ViewComponent::InvalidSlotNameError do
+ Class.new(ViewComponent::Base) do
+ renders_many :call_out_titles
+ end
+ end
+ end
+
+ def test_slot_names_can_start_with_call
+ assert_nothing_raised do
+ Class.new(ViewComponent::Base) do
+ renders_one :callhome_et
+ end
+ end
+ end
end
diff --git a/test/sandbox/test/test_helper_test.rb b/test/sandbox/test/test_helper_test.rb
index 90339a10fc..c55dfc0ebf 100644
--- a/test/sandbox/test/test_helper_test.rb
+++ b/test/sandbox/test/test_helper_test.rb
@@ -8,7 +8,7 @@ def test_with_request_url_inside_slots_path
render_inline(CurrentPageComponent.new)
end
- assert_selector("div", text: "Inside /slots")
+ assert_selector("div", text: "Inside /slots (GET /slots)")
end
def test_with_request_url_outside_slots_path
@@ -16,7 +16,15 @@ def test_with_request_url_outside_slots_path
render_inline(CurrentPageComponent.new)
end
- assert_selector("div", text: "Outside /slots")
+ assert_selector("div", text: "Outside /slots (GET /)")
+ end
+
+ def test_with_request_url_specifying_method
+ with_request_url "/create", method: "POST" do
+ render_inline(CurrentPageComponent.new)
+ end
+
+ assert_selector("div", text: "Outside /slots (POST /create)")
end
def test_with_request_url_under_constraint
diff --git a/test/view_component/engine_test.rb b/test/view_component/engine_test.rb
new file mode 100644
index 0000000000..5c5c037260
--- /dev/null
+++ b/test/view_component/engine_test.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class ViewComponent::EngineTest < ActionDispatch::IntegrationTest
+ def test_serve_static_previews?
+ app.config.public_file_server.enabled = false
+ refute ViewComponent::Engine.instance.serve_static_preview_assets?(app.config)
+ end
+end