storybook_rails 1.0.0

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