view_component_storybook 0.8.0 → 0.11.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -128
  3. data/app/controllers/view_component/storybook/stories_controller.rb +10 -11
  4. data/app/views/view_component/storybook/stories/show.html.erb +8 -1
  5. data/config/locales/en.yml +32 -0
  6. data/lib/view_component/storybook/content_concern.rb +42 -0
  7. data/lib/view_component/storybook/controls/base_options_config.rb +41 -0
  8. data/lib/view_component/storybook/controls/boolean_config.rb +7 -6
  9. data/lib/view_component/storybook/controls/color_config.rb +4 -5
  10. data/lib/view_component/storybook/controls/control_config.rb +24 -36
  11. data/lib/view_component/storybook/controls/controls_helpers.rb +76 -0
  12. data/lib/view_component/storybook/controls/custom_config.rb +52 -0
  13. data/lib/view_component/storybook/controls/date_config.rb +14 -11
  14. data/lib/view_component/storybook/controls/multi_options_config.rb +46 -0
  15. data/lib/view_component/storybook/controls/number_config.rb +9 -9
  16. data/lib/view_component/storybook/controls/object_config.rb +13 -5
  17. data/lib/view_component/storybook/controls/options_config.rb +17 -30
  18. data/lib/view_component/storybook/controls/simple_control_config.rb +48 -0
  19. data/lib/view_component/storybook/controls/text_config.rb +1 -1
  20. data/lib/view_component/storybook/controls.rb +5 -1
  21. data/lib/view_component/storybook/dsl/{controls_dsl.rb → legacy_controls_dsl.rb} +18 -21
  22. data/lib/view_component/storybook/dsl.rb +1 -2
  23. data/lib/view_component/storybook/engine.rb +13 -2
  24. data/lib/view_component/storybook/method_args/component_constructor_args.rb +23 -0
  25. data/lib/view_component/storybook/method_args/control_method_args.rb +91 -0
  26. data/lib/view_component/storybook/method_args/dry_initializer_component_constructor_args.rb +45 -0
  27. data/lib/view_component/storybook/method_args/method_args.rb +52 -0
  28. data/lib/view_component/storybook/method_args/method_parameters_names.rb +97 -0
  29. data/lib/view_component/storybook/method_args.rb +17 -0
  30. data/lib/view_component/storybook/slots/slot.rb +24 -0
  31. data/lib/view_component/storybook/slots/slot_config.rb +79 -0
  32. data/lib/view_component/storybook/slots.rb +14 -0
  33. data/lib/view_component/storybook/stories.rb +60 -10
  34. data/lib/view_component/storybook/story.rb +18 -0
  35. data/lib/view_component/storybook/story_config.rb +143 -15
  36. data/lib/view_component/storybook/tasks/write_stories_json.rake +6 -0
  37. data/lib/view_component/storybook/version.rb +1 -1
  38. data/lib/view_component/storybook.rb +25 -0
  39. metadata +64 -20
  40. data/lib/view_component/storybook/controls/array_config.rb +0 -36
  41. data/lib/view_component/storybook/dsl/story_dsl.rb +0 -39
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Storybook
5
+ module MethodArgs
6
+ class MethodParametersNames
7
+ REQ_KWARG_TYPE = :keyreq
8
+ KWARG_TYPES = [REQ_KWARG_TYPE, :key].freeze
9
+ REQ_ARG_TYPE = :req
10
+ ARG_TYPES = [REQ_ARG_TYPE, :opt].freeze
11
+ KWARG_REST = :keyrest
12
+ REST = :rest
13
+
14
+ attr_reader :target_method
15
+
16
+ def initialize(target_method)
17
+ @target_method = target_method
18
+ end
19
+
20
+ def arg_name(pos)
21
+ if pos < named_arg_count
22
+ arg_names[pos]
23
+ else
24
+ offset_pos = pos - named_arg_count
25
+ "#{rest_arg_name}#{offset_pos}".to_sym
26
+ end
27
+ end
28
+
29
+ def include_kwarg?(kwarg_name)
30
+ supports_keyrest? || kwarg_names.include?(kwarg_name)
31
+ end
32
+
33
+ def covers_required_kwargs?(names)
34
+ names.to_set >= req_kwarg_names.to_set
35
+ end
36
+
37
+ def max_arg_count
38
+ supports_rest? ? Float::INFINITY : named_arg_count
39
+ end
40
+
41
+ def min_arg_count
42
+ req_arg_count
43
+ end
44
+
45
+ def req_kwarg_names
46
+ @req_kwarg_names ||= parameters.map do |type, name|
47
+ name if type == REQ_KWARG_TYPE
48
+ end.compact
49
+ end
50
+
51
+ private
52
+
53
+ def parameters
54
+ @parameters ||= target_method.parameters
55
+ end
56
+
57
+ def kwarg_names
58
+ @kwarg_names ||= parameters.map do |type, name|
59
+ name if KWARG_TYPES.include?(type)
60
+ end.compact
61
+ end
62
+
63
+ def arg_names
64
+ @arg_names ||= parameters.map do |type, name|
65
+ name if ARG_TYPES.include?(type)
66
+ end.compact
67
+ end
68
+
69
+ def req_arg_names
70
+ @req_arg_names ||= parameters.map do |type, name|
71
+ name if type == REQ_ARG_TYPE
72
+ end.compact
73
+ end
74
+
75
+ def named_arg_count
76
+ @named_arg_count ||= arg_names.count
77
+ end
78
+
79
+ def req_arg_count
80
+ @req_arg_count ||= req_arg_names.count
81
+ end
82
+
83
+ def rest_arg_name
84
+ @rest_arg_name ||= parameters.map { |type, name| name if type == REST }.first
85
+ end
86
+
87
+ def supports_keyrest?
88
+ @supports_keyrest ||= parameters.map(&:first).include?(KWARG_REST)
89
+ end
90
+
91
+ def supports_rest?
92
+ @supports_rest ||= parameters.map(&:first).include?(REST)
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/dependencies/autoload"
4
+
5
+ module ViewComponent
6
+ module Storybook
7
+ module MethodArgs
8
+ extend ActiveSupport::Autoload
9
+
10
+ autoload :MethodArgs
11
+ autoload :MethodParametersNames
12
+ autoload :ControlMethodArgs
13
+ autoload :ComponentConstructorArgs
14
+ autoload :DryInitializerComponentConstructorArgs
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Storybook
5
+ module Slots
6
+ class Slot
7
+ attr_reader :component, :slot_name, :slot_method_args, :content_block
8
+
9
+ # delegate :args, :kwargs, :controls, to: :slot_method_args
10
+
11
+ def initialize(component, slot_name, slot_method_args, content_block)
12
+ @component = component
13
+ @slot_name = slot_name
14
+ @slot_method_args = slot_method_args
15
+ @content_block = content_block
16
+ end
17
+
18
+ def call(&block)
19
+ component.send(slot_name, *slot_method_args.args, **slot_method_args.kwargs, &block)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Storybook
5
+ module Slots
6
+ class SlotConfig
7
+ include ActiveModel::Validations
8
+ include ContentConcern
9
+
10
+ attr_reader :slot_name, :slot_method_args, :param, :content_block
11
+
12
+ validate :validate_slot_method_args
13
+
14
+ def initialize(slot_name, slot_method_args, param, content_block)
15
+ @slot_name = slot_name
16
+ @slot_method_args = slot_method_args
17
+ @param = param
18
+ @content_block = content_block
19
+ end
20
+
21
+ def self.from_component(component_class, slot_name, param, *args, **kwargs, &block)
22
+ new(
23
+ slot_name,
24
+ slot_method_args(component_class, slot_name, *args, **kwargs).with_param_prefix(param),
25
+ param,
26
+ block
27
+ )
28
+ end
29
+
30
+ def slot(componeont, params)
31
+ resolved_method_args = slot_method_args.resolve_method_args(params)
32
+ story_content_block = resolve_content_block(params)
33
+ Slot.new(componeont, slot_name, resolved_method_args, story_content_block)
34
+ end
35
+
36
+ def controls
37
+ list = slot_method_args.controls.dup
38
+ list << content_control if content_control
39
+ list
40
+ end
41
+
42
+ def content_param
43
+ "#{param}__content".to_sym
44
+ end
45
+
46
+ private
47
+
48
+ def validate_slot_method_args
49
+ return if slot_method_args.valid?
50
+
51
+ slot_method_args_errors = slot_method_args.errors.full_messages.join(', ')
52
+ errors.add(:slot_method_args, :invalid, errors: slot_method_args_errors)
53
+ end
54
+
55
+ def self.slot_method_args(component_class, slot_name, *args, **kwargs)
56
+ # Reverse engineer the signature of the slot so that we can validate control args to the slot
57
+ # The slot methods themselves just have rest params so we can't introspect them. Instead we
58
+ # look for 'renderable' details of the registered slot
59
+ # This approach is tightly coupled to internal ViewCopmonent apis and might prove to be brittle
60
+ registred_slot_name = component_class.slot_type(slot_name) == :collection_item ? ActiveSupport::Inflector.pluralize(slot_name).to_sym : slot_name
61
+
62
+ registered_slot = component_class.registered_slots[registred_slot_name]
63
+
64
+ if registered_slot[:renderable] || registered_slot[:renderable_class_name]
65
+ # The slot is a component - either a class or a string representing the class
66
+ component_class = registered_slot[:renderable] || component_class.const_get(registered_slot[:renderable_class_name])
67
+ MethodArgs::ComponentConstructorArgs.from_component_class(component_class, *args, **kwargs)
68
+ else
69
+ # the slot is a lamba or a simple content slot
70
+ slot_lamba = registered_slot[:renderable_function] || proc {}
71
+ MethodArgs::ControlMethodArgs.new(slot_lamba, *args, **kwargs)
72
+ end
73
+ end
74
+
75
+ private_class_method :slot_method_args
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/dependencies/autoload"
4
+
5
+ module ViewComponent
6
+ module Storybook
7
+ module Slots
8
+ extend ActiveSupport::Autoload
9
+
10
+ autoload :SlotConfig
11
+ autoload :Slot
12
+ end
13
+ end
14
+ end
@@ -4,19 +4,31 @@ module ViewComponent
4
4
  module Storybook
5
5
  class Stories
6
6
  extend ActiveSupport::DescendantsTracker
7
+ include ActiveModel::Validations
7
8
 
8
9
  class_attribute :story_configs, default: []
9
- class_attribute :parameters, :title, :stories_layout
10
+ class_attribute :stories_parameters, :stories_title, :stories_layout
11
+
12
+ validate :validate_story_configs
10
13
 
11
14
  class << self
15
+ def title(title = nil)
16
+ # if no argument is passed act like a getter
17
+ self.stories_title = title unless title.nil?
18
+ stories_title
19
+ end
20
+
12
21
  def story(name, component = default_component, &block)
13
- story_config = StoryConfig.configure(story_id(name), name, component, layout, &block)
22
+ story_config = StoryConfig.new(story_id(name), name, component, layout, &block)
23
+ story_config.instance_eval(&block)
14
24
  story_configs << story_config
15
25
  story_config
16
26
  end
17
27
 
18
- def parameters(**params)
19
- self.parameters = params
28
+ def parameters(params = nil)
29
+ # if no argument is passed act like a getter
30
+ self.stories_parameters = params unless params.nil?
31
+ stories_parameters
20
32
  end
21
33
 
22
34
  def layout(layout = nil)
@@ -26,6 +38,7 @@ module ViewComponent
26
38
  end
27
39
 
28
40
  def to_csf_params
41
+ validate!
29
42
  csf_params = { title: title }
30
43
  csf_params[:parameters] = parameters if parameters.present?
31
44
  csf_params[:stories] = story_configs.map(&:to_csf_params)
@@ -34,9 +47,7 @@ module ViewComponent
34
47
 
35
48
  def write_csf_json
36
49
  json_path = File.join(stories_path, "#{stories_name}.stories.json")
37
- File.open(json_path, "w") do |f|
38
- f.write(JSON.pretty_generate(to_csf_params))
39
- end
50
+ File.write(json_path, JSON.pretty_generate(to_csf_params))
40
51
  json_path
41
52
  end
42
53
 
@@ -56,7 +67,7 @@ module ViewComponent
56
67
  end
57
68
 
58
69
  # Find a component stories by its underscored class name.
59
- def find_stories(stories_name)
70
+ def find_story_configs(stories_name)
60
71
  all.find { |stories| stories.stories_name == stories_name }
61
72
  end
62
73
 
@@ -66,16 +77,29 @@ module ViewComponent
66
77
  end
67
78
 
68
79
  # find the story by name
69
- def find_story(name)
80
+ def find_story_config(name)
70
81
  story_configs.find { |config| config.name == name.to_sym }
71
82
  end
72
83
 
84
+ # validation - ActiveModel::Validations like but on the class vs the instance
85
+ def valid?
86
+ # use an instance so we can enjoy the benefits of ActiveModel::Validations
87
+ @validation_instance = new
88
+ @validation_instance.valid?
89
+ end
90
+
91
+ delegate :errors, to: :@validation_instance
92
+
93
+ def validate!
94
+ valid? || raise(ValidationError, @validation_instance)
95
+ end
96
+
73
97
  private
74
98
 
75
99
  def inherited(other)
76
100
  super(other)
77
101
  # setup class defaults
78
- other.title = other.stories_name.humanize.titlecase
102
+ other.stories_title = other.stories_name.humanize.titlecase
79
103
  other.story_configs = []
80
104
  end
81
105
 
@@ -95,6 +119,32 @@ module ViewComponent
95
119
  "#{stories_name}/#{name.to_s.parameterize}".underscore
96
120
  end
97
121
  end
122
+
123
+ protected
124
+
125
+ def validate_story_configs
126
+ story_configs.reject(&:valid?).each do |story_config|
127
+ story_errors = story_config.errors.full_messages.join(', ')
128
+ errors.add(:story_configs, :invalid_story, story_name: story_config.name, story_errors: story_errors)
129
+ end
130
+
131
+ story_names = story_configs.map(&:name)
132
+ duplicate_names = story_names.group_by(&:itself).map { |k, v| k if v.length > 1 }.compact
133
+ return if duplicate_names.empty?
134
+
135
+ duplicate_name_sentence = duplicate_names.map { |name| "'#{name}'" }.to_sentence
136
+ errors.add(:story_configs, :duplicate_stories, count: duplicate_names.count, duplicate_names: duplicate_name_sentence)
137
+ end
138
+
139
+ class ValidationError < StandardError
140
+ attr_reader :stories
141
+
142
+ def initialize(stories)
143
+ @stories = stories
144
+
145
+ super("#{@stories.class.name} invalid: (#{@stories.errors.full_messages.join(', ')})")
146
+ end
147
+ end
98
148
  end
99
149
  end
100
150
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Storybook
5
+ class Story
6
+ include ActiveModel::Validations
7
+
8
+ attr_reader :component, :content_block, :slots, :layout
9
+
10
+ def initialize(component, content_block, slots, layout)
11
+ @component = component
12
+ @content_block = content_block
13
+ @slots = slots
14
+ @layout = layout
15
+ end
16
+ end
17
+ end
18
+ end
@@ -4,19 +4,73 @@ module ViewComponent
4
4
  module Storybook
5
5
  class StoryConfig
6
6
  include ActiveModel::Validations
7
+ include ContentConcern
8
+ include Controls::ControlsHelpers
7
9
 
8
- attr_reader :id, :name, :component
9
- attr_accessor :controls, :parameters, :layout, :content_block
10
+ attr_reader :id, :name, :component_class
10
11
 
11
- def initialize(id, name, component, layout)
12
+ validate :validate_constructor_args, :validate_slots_args
13
+
14
+ def initialize(id, name, component_class, layout)
12
15
  @id = id
13
16
  @name = name
14
- @component = component
17
+ @component_class = component_class
15
18
  @layout = layout
16
- @controls = []
19
+ @slots ||= {}
20
+ end
21
+
22
+ def constructor(*args, **kwargs, &block)
23
+ @constructor_args = MethodArgs::ComponentConstructorArgs.from_component_class(
24
+ component_class,
25
+ *args,
26
+ **kwargs
27
+ )
28
+ content(nil, &block)
29
+
30
+ self
31
+ end
32
+
33
+ # Once deprecated block version is removed make this a private getter
34
+ def controls(&block)
35
+ if block_given?
36
+ ActiveSupport::Deprecation.warn("`controls` will be removed in v1.0.0. Use `#constructor` instead.")
37
+ controls_dsl = Dsl::LegacyControlsDsl.new
38
+ controls_dsl.instance_eval(&block)
39
+
40
+ controls_hash = controls_dsl.controls.index_by(&:param)
41
+ constructor(**controls_hash)
42
+ else
43
+ list = constructor_args.controls.dup
44
+ list << content_control if content_control
45
+ list += slots.flat_map(&:controls) if slots
46
+ list
47
+ end
48
+ end
49
+
50
+ def layout(layout = nil)
51
+ @layout = layout unless layout.nil?
52
+ @layout
53
+ end
54
+
55
+ def parameters(parameters = nil)
56
+ @parameters = parameters unless parameters.nil?
57
+ @parameters
58
+ end
59
+
60
+ def method_missing(method_name, *args, **kwargs, &block)
61
+ if component_class.slot_type(method_name)
62
+ slot(method_name, *args, **kwargs, &block)
63
+ else
64
+ super
65
+ end
66
+ end
67
+
68
+ def respond_to_missing?(method_name, _include_private = false)
69
+ component_class.slot_type(method_name).present?
17
70
  end
18
71
 
19
72
  def to_csf_params
73
+ validate!
20
74
  csf_params = { name: name, parameters: { server: { id: id } } }
21
75
  csf_params.deep_merge!(parameters: parameters) if parameters.present?
22
76
  controls.each do |control|
@@ -25,18 +79,92 @@ module ViewComponent
25
79
  csf_params
26
80
  end
27
81
 
28
- def values_from_params(params)
29
- controls.map do |control|
30
- value = control.value_from_param(params[control.param])
31
- value = control.value if value.nil? # nil only not falsey
32
- [control.param, value]
33
- end.to_h
82
+ def validate!
83
+ valid? || raise(ValidationError, self)
34
84
  end
35
85
 
36
- def self.configure(id, name, component, layout, &configuration)
37
- config = new(id, name, component, layout)
38
- ViewComponent::Storybook::Dsl::StoryDsl.evaluate!(config, &configuration)
39
- config
86
+ ##
87
+ # Build a Story from this config
88
+ # * Resolves the values of the constructor args from the params
89
+ # * constructs the component
90
+ # * resolve the content_control and content_block to a single block
91
+ # * builds a list of Slots by resolving their args from the params
92
+ def story(params)
93
+ # constructor_args.target_method is UnboundMethod so can't call it directly
94
+ component = constructor_args.call(params) do |*args, **kwargs|
95
+ component_class.new(*args, **kwargs)
96
+ end
97
+
98
+ story_content_block = resolve_content_block(params)
99
+
100
+ story_slots = slots.map do |slot_config|
101
+ slot_config.slot(component, params)
102
+ end
103
+
104
+ Storybook::Story.new(component, story_content_block, story_slots, layout)
105
+ end
106
+
107
+ class ValidationError < StandardError
108
+ attr_reader :story_config
109
+
110
+ def initialize(story_config)
111
+ @story_config = story_config
112
+
113
+ super("'#{@story_config.name}' invalid: (#{@story_config.errors.full_messages.join(', ')})")
114
+ end
115
+ end
116
+
117
+ private
118
+
119
+ def constructor_args
120
+ @constructor_args ||= MethodArgs::ComponentConstructorArgs.from_component_class(component_class)
121
+ end
122
+
123
+ def slots
124
+ @slots.values.flatten
125
+ end
126
+
127
+ def validate_constructor_args
128
+ return if constructor_args.valid?
129
+
130
+ constructor_args_errors = constructor_args.errors.full_messages.join(', ')
131
+ errors.add(:constructor_args, :invalid, errors: constructor_args_errors)
132
+ end
133
+
134
+ def validate_slots_args
135
+ slots.reject(&:valid?).each do |slot_config|
136
+ slot_errors = slot_config.errors.full_messages.join(', ')
137
+ errors.add(:slots, :invalid, errors: slot_errors)
138
+ end
139
+ end
140
+
141
+ def slot(slot_name, *args, **kwargs, &block)
142
+ # if the name is a slot then build a SlotConfig with slot_name and param the same
143
+ if component_class.slot_type(slot_name) == :collection_item
144
+ # generate a unique param generated by the count of slots with this name already added
145
+ @slots[slot_name] ||= []
146
+ slot_index = @slots[slot_name].count + 1
147
+ slot_config = Slots::SlotConfig.from_component(
148
+ component_class,
149
+ slot_name,
150
+ "#{slot_name}#{slot_index}".to_sym,
151
+ *args,
152
+ **kwargs,
153
+ &block
154
+ )
155
+ @slots[slot_name] << slot_config
156
+ else
157
+ slot_config = Slots::SlotConfig.from_component(
158
+ component_class,
159
+ slot_name,
160
+ slot_name,
161
+ *args,
162
+ **kwargs,
163
+ &block
164
+ )
165
+ @slots[slot_name] = slot_config
166
+ end
167
+ slot_config
40
168
  end
41
169
  end
42
170
  end
@@ -4,10 +4,16 @@ namespace :view_component_storybook do
4
4
  desc "Write CSF JSON stories for all Stories"
5
5
  task write_stories_json: :environment do
6
6
  puts "Writing Stories JSON"
7
+ exceptions = []
7
8
  ViewComponent::Storybook::Stories.all.each do |stories|
8
9
  json_path = stories.write_csf_json
9
10
  puts "#{stories.name} => #{json_path}"
11
+ rescue StandardError => e
12
+ exceptions << e
10
13
  end
14
+
15
+ raise StandardError, exceptions.map(&:message).join(", ") if exceptions.present?
16
+
11
17
  puts "Done"
12
18
  end
13
19
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ViewComponent
4
4
  module Storybook
5
- VERSION = "0.8.0"
5
+ VERSION = "0.11.0"
6
6
  end
7
7
  end
@@ -10,6 +10,10 @@ module ViewComponent
10
10
  autoload :Controls
11
11
  autoload :Stories
12
12
  autoload :StoryConfig
13
+ autoload :Story
14
+ autoload :Slots
15
+ autoload :ContentConcern
16
+ autoload :MethodArgs
13
17
  autoload :Dsl
14
18
 
15
19
  include ActiveSupport::Configurable
@@ -27,6 +31,27 @@ module ViewComponent
27
31
  #
28
32
  mattr_accessor :show_stories, instance_writer: false
29
33
 
34
+ # Set the entry route for component stories:
35
+ #
36
+ # config.view_component_storybook.stories_route = "/stories"
37
+ #
38
+ # Defaults to `/rails/stories` when `show_stories` is enabled.
39
+ #
40
+ mattr_accessor :stories_route, instance_writer: false do
41
+ "/rails/stories"
42
+ end
43
+
44
+ # :nocov:
45
+ if defined?(ViewComponent::Storybook::Engine)
46
+ ActiveSupport::Deprecation.warn(
47
+ "This manually engine loading is deprecated and will be removed in v1.0.0. " \
48
+ "Remove `require \"view_component/storybook/engine\"`."
49
+ )
50
+ elsif defined?(Rails::Engine)
51
+ require "view_component/storybook/engine"
52
+ end
53
+ # :nocov:
54
+
30
55
  ActiveSupport.run_load_hooks(:view_component_storybook, self)
31
56
  end
32
57
  end