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: xronos-i-am yykamei matheuspolicamilo +danigonza erinnachen ihollander svetlins @@ -211,6 +212,7 @@ ViewComponent is built by over a hundred members of the community, including: reeganviljoen thomascchen milk1000cc +aduth ## 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