diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8c271729b..ab2484fdb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,6 +14,10 @@ nav_order: 6 *Joel Hawksley* +* Raise `ViewComponent::UnknownConfigOptionError` when setting a `config.view_component` option that doesn't exist, rather than ignoring it. This includes options renamed or removed in 4.0, such as `preview_controller` (now `previews.controller`), and the error names the replacement. Replacing `previews` or `generate` with a hash preserves this validation. + + *Fiona Burcombe* + ## 4.20.0 * Fix `invalid byte sequence in UTF-8` errors when precomputing cache digests for components loaded from encrypted or binary source files. diff --git a/lib/view_component/config.rb b/lib/view_component/config.rb index 0ad336838..16632cf18 100644 --- a/lib/view_component/config.rb +++ b/lib/view_component/config.rb @@ -1,9 +1,66 @@ # frozen_string_literal: true -require "view_component/deprecation" +require "view_component/errors" module ViewComponent class Config + # An `ActiveSupport::OrderedOptions` that raises when an option it doesn't + # know about is set, rather than silently storing it. + class Options < ActiveSupport::OrderedOptions # :nodoc: + ROOT_NAMESPACE = "config.view_component" + + def initialize(namespace, allowed_keys, default: nil, renamed: {}, removed: {}) + super(default) + @namespace = namespace + @allowed_keys = allowed_keys + @renamed = renamed + @removed = removed + end + + def []=(key, value) + key = key.to_sym + + if @allowed_keys.include?(key) + if self[key].is_a?(Options) + raise TypeError, "#{@namespace}.#{key} must be a Hash" unless value.is_a?(Hash) + + value = self[key].dup.clear.tap do |options| + value.each_pair { |nested_key, nested_value| options[nested_key] = nested_value } + end + end + super + else + raise UnknownConfigOptionError.new( + "#{@namespace}.#{key}", + renamed_to: @renamed[key] && "#{ROOT_NAMESPACE}.#{@renamed[key]}", + removed: @removed.key?(key), + hint: @removed[key], + valid_options: @allowed_keys + ) + end + end + end + + # Options renamed in 4.0, mapped to their new name. + RENAMED_OPTIONS = { + preview_controller: "previews.controller", + preview_route: "previews.route", + preview_paths: "previews.paths", + show_previews: "previews.enabled", + default_preview_layout: "previews.default_layout", + component_parent_class: "generate.parent_class", + view_component_path: "generate.path" + }.freeze + + # Options removed in 4.0, mapped to a hint about what to do instead. + REMOVED_OPTIONS = { + test_controller: "Use the `vc_test_controller_class` test helper instead.", + render_monkey_patch_enabled: nil, + use_deprecated_instrumentation_name: "Events always use the `render.view_component` name.", + capture_compatibility_patch_enabled: nil, + show_previews_source: "Consider using Lookbook instead." + }.freeze + class << self # `new` without any arguments initializes the default configuration, but # it's important to differentiate in case that's no longer the case in @@ -11,11 +68,16 @@ class << self alias_method :default, :new def defaults - ActiveSupport::OrderedOptions.new.merge!({ - generate: default_generate_options, - previews: default_previews_options, - instrumentation_enabled: false - }) + options = Options.new( + Options::ROOT_NAMESPACE, + %i[generate previews instrumentation_enabled], + renamed: RENAMED_OPTIONS, + removed: REMOVED_OPTIONS + ) + options.generate = default_generate_options + options.previews = default_previews_options + options.instrumentation_enabled = false + options end # @!attribute generate @@ -156,14 +218,22 @@ def registered_rails_engines_with_previews end def default_generate_options - options = ActiveSupport::OrderedOptions.new(false) + options = Options.new( + "#{Options::ROOT_NAMESPACE}.generate", + %i[path preview_path parent_class sidecar stimulus_controller typescript locale distinct_locale_files preview use_component_path_for_rspec_tests], + default: false, + renamed: RENAMED_OPTIONS + ) options.preview_path = "" options.path = "app/components" options end def default_previews_options - options = ActiveSupport::OrderedOptions.new + options = Options.new( + "#{Options::ROOT_NAMESPACE}.previews", + %i[controller route enabled default_layout paths] + ) options.controller = "ViewComponentsController" options.route = "/rails/view_components" options.enabled = defined?(Rails.env) && (Rails.env.development? || Rails.env.test?) diff --git a/lib/view_component/errors.rb b/lib/view_component/errors.rb index af3059d75..e0552e573 100644 --- a/lib/view_component/errors.rb +++ b/lib/view_component/errors.rb @@ -267,4 +267,20 @@ def initialize(component_name) super(MESSAGE.gsub("COMPONENT", component_name.to_s)) end end + + class UnknownConfigOptionError < StandardError + def initialize(option, renamed_to: nil, removed: false, hint: nil, valid_options: []) + message = + if renamed_to + "`#{option}` was renamed to `#{renamed_to}` in ViewComponent 4.0." + elsif removed + "`#{option}` was removed in ViewComponent 4.0." + else + "`#{option}` isn't a ViewComponent configuration option. " \ + "Valid options are: #{valid_options.join(", ")}." + end + + super([message, hint].compact.join(" ")) + end + end end diff --git a/test/sandbox/test/config_test.rb b/test/sandbox/test/config_test.rb index 9347a6ac1..2cfbd8903 100644 --- a/test/sandbox/test/config_test.rb +++ b/test/sandbox/test/config_test.rb @@ -16,6 +16,137 @@ def test_defaults_are_correct assert_equal @config.previews.paths, ["#{Rails.root}/test/components/previews"] end + def test_setting_documented_options_does_not_raise + @config.instrumentation_enabled = true + @config.previews.controller = "MyPreviewController" + @config.previews.route = "/my_previews" + @config.previews.enabled = false + @config.previews.default_layout = "preview_layout" + @config.previews.paths = [] + %i[ + path preview_path parent_class sidecar stimulus_controller typescript + locale distinct_locale_files preview use_component_path_for_rspec_tests + ].each { |option| @config.generate[option] = true } + + assert_equal true, @config.generate.typescript + end + + def test_setting_an_option_renamed_in_4_0_raises + error = assert_raises(ViewComponent::UnknownConfigOptionError) do + @config.preview_controller = "MyPreviewController" + end + + assert_equal( + "`config.view_component.preview_controller` was renamed to " \ + "`config.view_component.previews.controller` in ViewComponent 4.0.", + error.message + ) + end + + def test_setting_an_unknown_option_with_hash_syntax_raises + assert_raises(ViewComponent::UnknownConfigOptionError) do + @config[:preview_controller] = "MyPreviewController" + end + end + + def test_setting_a_generate_option_renamed_in_4_0_raises + error = assert_raises(ViewComponent::UnknownConfigOptionError) do + @config.generate.component_parent_class = "MyBaseComponent" + end + + assert_includes error.message, "`config.view_component.generate.parent_class`" + end + + def test_setting_an_option_removed_in_4_0_raises + error = assert_raises(ViewComponent::UnknownConfigOptionError) do + @config.test_controller = "MyController" + end + + assert_equal( + "`config.view_component.test_controller` was removed in ViewComponent 4.0. " \ + "Use the `vc_test_controller_class` test helper instead.", + error.message + ) + end + + def test_setting_a_misspelled_option_raises + error = assert_raises(ViewComponent::UnknownConfigOptionError) do + @config.instrumentaton_enabled = true + end + + assert_equal( + "`config.view_component.instrumentaton_enabled` isn't a ViewComponent configuration option. " \ + "Valid options are: generate, previews, instrumentation_enabled.", + error.message + ) + end + + def test_setting_a_misspelled_nested_option_raises + error = assert_raises(ViewComponent::UnknownConfigOptionError) do + @config.previews.contoller = "MyPreviewController" + end + + assert_includes error.message, "`config.view_component.previews.contoller`" + end + + def test_reading_an_unknown_option_returns_nil + assert_nil @config.preview_controller + end + + def test_duplicated_options_are_strict + assert_raises(ViewComponent::UnknownConfigOptionError) do + @config.generate.dup.component_parent_class = "MyBaseComponent" + end + end + + def test_generate_options_default_to_false + assert_equal false, @config.generate.typescript + end + + def test_replacing_nested_options_preserves_strictness + %i[previews generate].each do |namespace| + @config[namespace] = ActiveSupport::OrderedOptions.new + + assert_raises(ViewComponent::UnknownConfigOptionError) do + @config[namespace].typo = true + end + end + assert_equal false, @config.generate.typescript + end + + def test_replacing_nested_options_preserves_values + @config.previews = {"controller" => "MyPreviewController"} + @config.generate = {parent_class: "MyBaseComponent"} + + assert_equal({controller: "MyPreviewController"}, @config.previews) + assert_equal({parent_class: "MyBaseComponent"}, @config.generate) + assert_equal false, @config.generate.typescript + assert_raises(ViewComponent::UnknownConfigOptionError) do + @config.generate.component_parent_class = "OtherComponent" + end + end + + def test_replacing_nested_options_rejects_unknown_keys + original = @config.previews + replacement = ActiveSupport::OrderedOptions.new + replacement.contoller = "MyPreviewController" + + error = assert_raises(ViewComponent::UnknownConfigOptionError) do + @config.previews = replacement + end + + assert_includes error.message, "`config.view_component.previews.contoller`" + assert_same original, @config.previews + end + + def test_replacing_nested_options_rejects_non_hash_values + %i[previews generate].each do |namespace| + original = @config[namespace] + assert_raises(TypeError) { @config[namespace] = nil } + assert_same original, @config[namespace] + end + end + def test_all_methods_are_documented require "yard" require "rake"