Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
86 changes: 78 additions & 8 deletions lib/view_component/config.rb
Original file line number Diff line number Diff line change
@@ -1,21 +1,83 @@
# 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
# future.
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
Expand Down Expand Up @@ -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?)
Expand Down
16 changes: 16 additions & 0 deletions lib/view_component/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
131 changes: 131 additions & 0 deletions test/sandbox/test/config_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading