storybook_rails 1.0.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.
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionView
4
+ module Storybook
5
+ module Controls
6
+ class ControlConfig
7
+ include ActiveModel::Validations
8
+
9
+ attr_reader :param, :value, :name
10
+
11
+ validates :param, presence: true
12
+
13
+ def initialize(param, value, name: nil)
14
+ @param = param
15
+ @value = value
16
+ @name = name || param.to_s.humanize.titlecase
17
+ end
18
+
19
+ def to_csf_params
20
+ {
21
+ args: { param => csf_value },
22
+ argTypes: { param => { control: csf_control_params, name: name } }
23
+ }
24
+ end
25
+
26
+ def value_from_param(param)
27
+ param
28
+ end
29
+
30
+ private
31
+
32
+ # provide extension points for subclasses to vary the value
33
+ def csf_value
34
+ value
35
+ end
36
+
37
+ def csf_control_params
38
+ { type: type }
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionView
4
+ module Storybook
5
+ module Controls
6
+ class DateConfig < ControlConfig
7
+ def initialize(param, value, name: nil)
8
+ super(param, value, name: name)
9
+ end
10
+
11
+ def type
12
+ :date
13
+ end
14
+
15
+ def value_from_param(param)
16
+ if param.is_a?(String)
17
+ DateTime.iso8601(param)
18
+ else
19
+ super(param)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def csf_value
26
+ params_value = value
27
+ params_value = params_value.in_time_zone if params_value.is_a?(Date)
28
+ params_value = params_value.iso8601 if params_value.is_a?(Time)
29
+ params_value
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionView
4
+ module Storybook
5
+ module Controls
6
+ class NumberConfig < ControlConfig
7
+ TYPES = %i[number range].freeze
8
+
9
+ attr_reader :type, :min, :max, :step
10
+
11
+ validates :type, presence: true
12
+ validates :type, inclusion: { in: TYPES }, unless: -> { type.nil? }
13
+
14
+ def initialize(type, param, value, min: nil, max: nil, step: nil, name: nil)
15
+ super(param, value, name: name)
16
+ @type = type
17
+ @min = min
18
+ @max = max
19
+ @step = step
20
+ end
21
+
22
+ def value_from_param(param)
23
+ if param.is_a?(String) && param.present?
24
+ (param.to_f % 1) > 0 ? param.to_f : param.to_i
25
+ else
26
+ super(param)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def csf_control_params
33
+ params = super
34
+ params.merge(min: min, max: max, step: step).compact
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionView
4
+ module Storybook
5
+ module Controls
6
+ class ObjectConfig < ControlConfig
7
+ def type
8
+ :object
9
+ end
10
+
11
+ def value_from_param(param)
12
+ if param.is_a?(String)
13
+ JSON.parse(param).deep_symbolize_keys
14
+ else
15
+ super(param)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionView
4
+ module Storybook
5
+ module Controls
6
+ class OptionsConfig < ControlConfig
7
+ class << self
8
+ # support the options being a Hash or an Array. Storybook supports either.
9
+ def inclusion_in(config)
10
+ case config.options
11
+ when Hash
12
+ config.options.values
13
+ when Array
14
+ config.options
15
+ end
16
+ end
17
+ end
18
+
19
+ TYPES = %i[select multi-select radio inline-radio check inline-check].freeze
20
+
21
+ attr_reader :type, :options, :symbol_value
22
+
23
+ validates :type, :options, presence: true
24
+ validates :type, inclusion: { in: TYPES }, unless: -> { type.nil? }
25
+ validates :value, inclusion: { in: method(:inclusion_in) }, unless: -> { options.nil? || value.nil? }
26
+
27
+ def initialize(type, param, options, default_value, name: nil)
28
+ super(param, default_value, name: name)
29
+ @type = type
30
+ @options = options
31
+ @symbol_value = default_value.is_a?(Symbol)
32
+ end
33
+
34
+ def value_from_param(param)
35
+ if param.is_a?(String) && symbol_value
36
+ param.to_sym
37
+ else
38
+ super(param)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def csf_control_params
45
+ super.merge(options: options)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionView
4
+ module Storybook
5
+ module Controls
6
+ class TextConfig < ControlConfig
7
+ def type
8
+ :text
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/dependencies/autoload"
4
+
5
+ module ActionView
6
+ module Storybook
7
+ module Dsl
8
+ extend ActiveSupport::Autoload
9
+
10
+ autoload :StoryDsl
11
+ autoload :ControlsDsl
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionView
4
+ module Storybook
5
+ module Dsl
6
+ class ControlsDsl
7
+ attr_reader :controls
8
+
9
+ def initialize(story_config)
10
+ @story_config = story_config
11
+ @controls = []
12
+ end
13
+
14
+ def text(param, value, name: nil)
15
+ controls << Controls::TextConfig.new(param, value, name: name)
16
+ end
17
+
18
+ def boolean(param, value, name: nil)
19
+ controls << Controls::BooleanConfig.new(param, value, name: name)
20
+ end
21
+
22
+ def number(param, value, name: nil, min: nil, max: nil, step: nil)
23
+ controls << Controls::NumberConfig.new(:number, param, value, name: name, min: min, max: max, step: step)
24
+ end
25
+
26
+ def range(param, value, name: nil, min: nil, max: nil, step: nil)
27
+ controls << Controls::NumberConfig.new(:range, param, value, name: name, min: min, max: max, step: step)
28
+ end
29
+
30
+ def color(param, value, name: nil, preset_colors: nil)
31
+ controls << Controls::ColorConfig.new(param, value, name: name, preset_colors: preset_colors)
32
+ end
33
+
34
+ def object(param, value, name: nil)
35
+ controls << Controls::ObjectConfig.new(param, value, name: name)
36
+ end
37
+
38
+ def select(param, options, value, name: nil)
39
+ controls << Controls::OptionsConfig.new(:select, param, options, value, name: name)
40
+ end
41
+
42
+ def multi_select(param, options, value, name: nil)
43
+ controls << Controls::OptionsConfig.new(:'multi-select', param, options, value, name: name)
44
+ end
45
+
46
+ def radio(param, options, value, name: nil)
47
+ controls << Controls::OptionsConfig.new(:radio, param, options, value, name: name)
48
+ end
49
+
50
+ def inline_radio(param, options, value, name: nil)
51
+ controls << Controls::OptionsConfig.new(:'inline-radio', param, options, value, name: name)
52
+ end
53
+
54
+ def check(param, options, value, name: nil)
55
+ controls << Controls::OptionsConfig.new(:check, param, options, value, name: name)
56
+ end
57
+
58
+ def inline_check(param, options, value, name: nil)
59
+ controls << Controls::OptionsConfig.new(:'inline-check', param, options, value, name: name)
60
+ end
61
+
62
+ def array(param, value, separator = ",", name: nil)
63
+ controls << Controls::ArrayConfig.new(param, value, separator, name: name)
64
+ end
65
+
66
+ def date(param, value, name: nil)
67
+ controls << Controls::DateConfig.new(param, value, name: name)
68
+ end
69
+
70
+ def respond_to_missing?(_method, *)
71
+ true
72
+ end
73
+
74
+ def method_missing(method, *args)
75
+ value = args.first
76
+ control_method = case value
77
+ when Date
78
+ :date
79
+ when Array
80
+ :array
81
+ when Hash
82
+ :object
83
+ when Numeric
84
+ :number
85
+ when TrueClass, FalseClass
86
+ :boolean
87
+ when String
88
+ :text
89
+ end
90
+ if control_method
91
+ send(control_method, method, *args)
92
+ else
93
+ super
94
+ end
95
+ end
96
+
97
+ Controls = ActionView::Storybook::Controls
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionView
4
+ module Storybook
5
+ module Dsl
6
+ class StoryDsl
7
+ def self.evaluate!(story_config, &block)
8
+ new(story_config).instance_eval(&block)
9
+ end
10
+
11
+ def parameters(**params)
12
+ @story_config.parameters = params
13
+ end
14
+
15
+ def controls(&block)
16
+ controls_dsl = ControlsDsl.new(story_config)
17
+ controls_dsl.instance_eval(&block)
18
+ @story_config.controls = controls_dsl.controls
19
+ end
20
+
21
+ def layout(layout)
22
+ @story_config.layout = layout
23
+ end
24
+
25
+ def content(&block)
26
+ @story_config.content_block = block
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :story_config
32
+
33
+ def initialize(story_config)
34
+ @story_config = story_config
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "action_view/storybook"
5
+
6
+ module ActionView
7
+ module Storybook
8
+ class Engine < Rails::Engine
9
+ config.storybook_rails = ActiveSupport::OrderedOptions.new
10
+
11
+ initializer "storybook_rails.set_configs" do |app|
12
+ options = app.config.storybook_rails
13
+
14
+ options.show_stories = Rails.env.development? if options.show_stories.nil?
15
+
16
+ if options.show_stories
17
+ options.stories_path ||= defined?(Rails.root) ? Rails.root.join("test/components/stories") : nil
18
+ end
19
+
20
+ ActiveSupport.on_load(:storybook_rails) do
21
+ options.each { |k, v| send("#{k}=", v) }
22
+ end
23
+ end
24
+
25
+ initializer "storybook_rails.set_autoload_paths" do |app|
26
+ options = app.config.storybook_rails
27
+
28
+ ActiveSupport::Dependencies.autoload_paths << options.stories_path if options.show_stories && options.stories_path
29
+ end
30
+
31
+ config.after_initialize do |app|
32
+ options = app.config.storybook_rails
33
+
34
+ if options.show_stories
35
+ app.routes.prepend do
36
+ get "storybook/*stories/:story" => "action_view/storybook/stories#show"
37
+ end
38
+ end
39
+ end
40
+
41
+ rake_tasks do
42
+ load File.join(__dir__, "tasks/write_stories_json.rake")
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/dependencies/autoload"
4
+
5
+ module ActionView
6
+ module Storybook
7
+ module Helpers
8
+ extend ActiveSupport::Autoload
9
+
10
+ autoload :story_params
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ module StoryParamsHelper
2
+ def story_params
3
+ params.dig(:story_params)
4
+ end
5
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionView
4
+ module Storybook
5
+ class Stories
6
+ extend ActiveSupport::DescendantsTracker
7
+ include ActiveModel::Validations
8
+
9
+ class_attribute :story_configs, default: []
10
+ class_attribute :parameters, :title, :stories_layout
11
+
12
+ validate :valid_story_configs
13
+
14
+ class << self
15
+ def story(name, template = default_template, &block)
16
+ story_config = StoryConfig.configure(story_id(name), name, layout, template, &block)
17
+ story_configs << story_config
18
+ story_config
19
+ end
20
+
21
+ def parameters(**params)
22
+ self.parameters = params
23
+ end
24
+
25
+ def layout(layout = nil)
26
+ # if no argument is passed act like a getter
27
+ self.stories_layout = layout unless layout.nil?
28
+ stories_layout
29
+ end
30
+
31
+ def to_csf_params
32
+ validate!
33
+ csf_params = { title: title }
34
+ csf_params[:parameters] = parameters if parameters.present?
35
+ csf_params[:stories] = story_configs.map(&:to_csf_params)
36
+ csf_params
37
+ end
38
+
39
+ def write_csf_json
40
+ json_path = File.join(stories_path, "#{stories_name}.stories.json")
41
+ File.open(json_path, "w") do |f|
42
+ f.write(JSON.pretty_generate(to_csf_params))
43
+ end
44
+ json_path
45
+ end
46
+
47
+ def stories_name
48
+ name.chomp("Stories").underscore
49
+ end
50
+
51
+ # Returns all component stories classes.
52
+ def all
53
+ load_stories if descendants.empty?
54
+ descendants
55
+ end
56
+
57
+ # Returns +true+ if the stories exist.
58
+ def stories_exists?(stories_name)
59
+ all.any? { |stories| stories.stories_name == stories_name }
60
+ end
61
+
62
+ # Find a component stories by its underscored class name.
63
+ def find_stories(stories_name)
64
+ all.find { |stories| stories.stories_name == stories_name }
65
+ end
66
+
67
+ # Returns +true+ if the story of the component stories exists.
68
+ def story_exists?(name)
69
+ story_configs.map(&:name).include?(name.to_sym)
70
+ end
71
+
72
+ # find the story by name
73
+ def find_story(name)
74
+ story_configs.find { |config| config.name == name.to_sym }
75
+ end
76
+
77
+ # validation - ActiveModel::Validations like but on the class vs the instance
78
+ def valid?
79
+ # use an instance so we can enjoy the benefits of ActiveModel::Validations
80
+ @validation_instance = new
81
+ @validation_instance.valid?
82
+ end
83
+
84
+ delegate :errors, to: :@validation_instance
85
+
86
+ def validate!
87
+ valid? || raise(ActiveModel::ValidationError, @validation_instance)
88
+ end
89
+
90
+ private
91
+
92
+ def inherited(other)
93
+ super(other)
94
+ # setup class defaults
95
+ other.title = other.stories_name.humanize.titlecase
96
+ other.story_configs = []
97
+ end
98
+
99
+ def default_template
100
+ "#{name.chomp("Stories").underscore}_stories"
101
+ end
102
+
103
+ def load_stories
104
+ if stories_path
105
+ Dir["#{stories_path}/**/*_stories.rb"].sort.each do |file|
106
+ require_dependency file
107
+ end
108
+ end
109
+ end
110
+
111
+ def stories_path
112
+ Storybook.stories_path
113
+ end
114
+
115
+ def story_id(name)
116
+ "#{stories_name}/#{name.to_s.parameterize}".underscore
117
+ end
118
+ end
119
+
120
+ protected
121
+
122
+ def valid_story_configs
123
+ story_configs.reject(&:valid?).each do |story_config|
124
+ errors.add(:story_configs, :invalid, value: story_config)
125
+ end
126
+
127
+ story_names = story_configs.map(&:name)
128
+ duplicate_names = story_names.group_by(&:itself).map { |k, v| k if v.length > 1 }.compact
129
+ return if duplicate_names.empty?
130
+
131
+ errors.add(:story_configs, :invalid, message: "Stories #{'names'.pluralize(duplicate_names.count)} #{duplicate_names.to_sentence} are repeated")
132
+ end
133
+ end
134
+ end
135
+ end