view_component_storybook 0.8.0 → 0.11.0

Sign up to get free protection for your applications and to get access to all the features.
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