view_component-storybook 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (30) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +21 -0
  4. data/docs/CHANGELOG.md +69 -0
  5. data/lib/view_component/storybook/collections/controls_collection.rb +80 -0
  6. data/lib/view_component/storybook/collections/layout_collection.rb +37 -0
  7. data/lib/view_component/storybook/collections/parameters_collection.rb +40 -0
  8. data/lib/view_component/storybook/collections/stories_collection.rb +31 -0
  9. data/lib/view_component/storybook/collections/valid_for_story_concern.rb +15 -0
  10. data/lib/view_component/storybook/collections.rb +17 -0
  11. data/lib/view_component/storybook/controls/base_options.rb +30 -0
  12. data/lib/view_component/storybook/controls/boolean.rb +30 -0
  13. data/lib/view_component/storybook/controls/color.rb +26 -0
  14. data/lib/view_component/storybook/controls/control.rb +34 -0
  15. data/lib/view_component/storybook/controls/date.rb +32 -0
  16. data/lib/view_component/storybook/controls/multi_options.rb +44 -0
  17. data/lib/view_component/storybook/controls/number.rb +38 -0
  18. data/lib/view_component/storybook/controls/object.rb +28 -0
  19. data/lib/view_component/storybook/controls/options.rb +36 -0
  20. data/lib/view_component/storybook/controls/simple_control.rb +47 -0
  21. data/lib/view_component/storybook/controls/text.rb +13 -0
  22. data/lib/view_component/storybook/controls.rb +23 -0
  23. data/lib/view_component/storybook/engine.rb +76 -0
  24. data/lib/view_component/storybook/stories.rb +120 -0
  25. data/lib/view_component/storybook/stories_parser.rb +48 -0
  26. data/lib/view_component/storybook/story.rb +25 -0
  27. data/lib/view_component/storybook/tasks/view_component_storybook.rake +35 -0
  28. data/lib/view_component/storybook/version.rb +7 -0
  29. data/lib/view_component/storybook.rb +63 -0
  30. metadata +285 -0
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Storybook
5
+ module Controls
6
+ ##
7
+ # A simple Control Config maps to one Storybook Control
8
+ # It has a value and pulls its value from params by key
9
+ class SimpleControl < Control
10
+ def initialize(param, default: nil, name: nil, description: nil, **opts)
11
+ super(param, default: default, name: name, description: description, **opts)
12
+ end
13
+
14
+ def to_csf_params
15
+ validate!
16
+ {
17
+ args: { param => csf_value },
18
+ argTypes: {
19
+ param => { control: csf_control_params, name: name, description: description }.compact
20
+ }
21
+ }
22
+ end
23
+
24
+ def parse_param_value(value)
25
+ value
26
+ end
27
+
28
+ private
29
+
30
+ # provide extension points for subclasses to vary the value
31
+ def type
32
+ # :nocov:
33
+ raise NotImplementedError
34
+ # :nocov:
35
+ end
36
+
37
+ def csf_value
38
+ default
39
+ end
40
+
41
+ def csf_control_params
42
+ { type: type }
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Storybook
5
+ module Controls
6
+ class Text < SimpleControl
7
+ def type
8
+ :text
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/dependencies/autoload"
4
+
5
+ module ViewComponent
6
+ module Storybook
7
+ module Controls
8
+ extend ActiveSupport::Autoload
9
+
10
+ autoload :Control
11
+ autoload :SimpleControl
12
+ autoload :Text
13
+ autoload :Boolean
14
+ autoload :Color
15
+ autoload :Number
16
+ autoload :BaseOptions
17
+ autoload :Options
18
+ autoload :MultiOptions
19
+ autoload :Date
20
+ autoload :Object
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "view_component"
4
+ require "action_cable/engine"
5
+ require "yard"
6
+
7
+ module ViewComponent
8
+ module Storybook
9
+ class Engine < Rails::Engine
10
+ config.view_component_storybook = ActiveSupport::OrderedOptions.new
11
+ config.view_component_storybook.stories_paths ||= []
12
+
13
+ initializer "view_component_storybook.set_configs" do |app|
14
+ options = app.config.view_component_storybook
15
+
16
+ options.show_stories = Rails.env.development? if options.show_stories.nil?
17
+ options.stories_route ||= "/rails/stories"
18
+
19
+ if options.show_stories && (defined?(Rails.root) && Rails.root.join("test/components/stories").exist?)
20
+ options.stories_paths << Rails.root.join("test/components/stories").to_s
21
+
22
+ end
23
+
24
+ options.stories_title_generator ||= ViewComponent::Storybook.stories_title_generator
25
+
26
+ ActiveSupport.on_load(:view_component_storybook) do
27
+ options.each { |k, v| send("#{k}=", v) }
28
+ end
29
+ end
30
+
31
+ initializer "view_component_storybook.set_autoload_paths" do |app|
32
+ options = app.config.view_component_storybook
33
+
34
+ if options.show_stories && !options.stories_paths.empty?
35
+ paths_to_add = options.stories_paths - ActiveSupport::Dependencies.autoload_paths
36
+ ActiveSupport::Dependencies.autoload_paths.concat(paths_to_add) if paths_to_add.any?
37
+ end
38
+ end
39
+
40
+ initializer "view_component_storybook.parser.stories_load_callback" do
41
+ parser.after_parse do |code_objects|
42
+ Engine.stories.load(code_objects.all(:class))
43
+ end
44
+ end
45
+
46
+ config.after_initialize do
47
+ parser.parse
48
+ end
49
+
50
+ rake_tasks do
51
+ load File.join(__dir__, "tasks/view_component_storybook.rake")
52
+ end
53
+
54
+ def parser
55
+ @parser ||= StoriesParser.new(ViewComponent::Storybook.stories_paths)
56
+ end
57
+
58
+ class << self
59
+ def stories
60
+ @stories ||= Collections::StoriesCollection.new
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ # :nocov:
68
+ unless defined?(ViewComponent::Storybook::Stories)
69
+ ActiveSupport::Deprecation.warn(
70
+ "This manually engine loading is deprecated and will be removed in v1.0.0. " \
71
+ "Remove `require \"view_component/storybook/engine\"`."
72
+ )
73
+
74
+ require "view_component/storybook"
75
+ end
76
+ # :nocov:
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Storybook
5
+ class Stories < ViewComponent::Preview
6
+ class << self
7
+ def title(title = nil)
8
+ @stories_title = title
9
+ end
10
+
11
+ def parameters(params, only: nil, except: nil)
12
+ parameters_collection.add(params, only: only, except: except)
13
+ end
14
+
15
+ def layout(layout, only: nil, except: nil)
16
+ layout_collection.add(layout, only: only, except: except)
17
+ end
18
+
19
+ def control(param, as:, **opts)
20
+ controls.add(param, as: as, **opts)
21
+ end
22
+
23
+ def stories_name
24
+ name.chomp("Stories").underscore
25
+ end
26
+
27
+ def preview_name
28
+ stories_name
29
+ end
30
+
31
+ def to_csf_params
32
+ csf_params = { title: stories_title }
33
+ csf_params[:parameters] = parameters_collection.for_all if parameters_collection.for_all.present?
34
+ csf_params[:stories] = stories.map(&:to_csf_params)
35
+ csf_params
36
+ end
37
+
38
+ def write_csf_json
39
+ File.write(stories_json_path, JSON.pretty_generate(to_csf_params))
40
+ stories_json_path
41
+ end
42
+
43
+ def stories
44
+ @stories ||= story_names.map { |name| Story.new(story_id(name), name, parameters_collection.for_story(name), controls.for_story(name)) }
45
+ end
46
+
47
+ # find the story by name
48
+ def find_story(name)
49
+ stories.find { |story| story.name == name.to_sym }
50
+ end
51
+
52
+ # Returns the arguments for rendering of the component in its layout
53
+ def render_args(story_name, params: {})
54
+ # mostly reimplementing the super method but adding logic to parse the params through the controls and find the layout
55
+ story_params_names = instance_method(story_name).parameters.map(&:last)
56
+ provided_params = params.slice(*story_params_names).to_h.symbolize_keys
57
+
58
+ story = find_story(story_name)
59
+
60
+ control_parsed_params = provided_params.to_h do |param, value|
61
+ control = story.controls.find { |c| c.param == param }
62
+ value = control.parse_param_value(value) if control
63
+
64
+ [param, value]
65
+ end
66
+
67
+ result = control_parsed_params.empty? ? new.public_send(story_name) : new.public_send(story_name, **control_parsed_params)
68
+ result ||= {}
69
+ result[:template] = preview_example_template_path(story_name) if result[:template].nil?
70
+ @layout = layout_collection.for_story(story_name.to_sym)
71
+ result.merge(layout: @layout)
72
+ end
73
+
74
+ attr_reader :code_object, :stories_json_path
75
+
76
+ def code_object=(object)
77
+ @code_object = object
78
+ @stories_json_path ||= begin
79
+ dir = File.dirname(object.file)
80
+ json_filename = object.path.demodulize.underscore
81
+
82
+ File.join(dir, "#{json_filename}.stories.json")
83
+ end
84
+
85
+ controls.code_object = object
86
+
87
+ # ordering of public_instance_methods isn't consistent
88
+ # use the code_object to sort the methods to the order that they're declared
89
+ @story_names = object.meths.select { |m| story_names.include?(m.name) }.map(&:name)
90
+ end
91
+
92
+ private
93
+
94
+ def controls
95
+ @controls ||= Collections::ControlsCollection.new
96
+ end
97
+
98
+ def stories_title
99
+ @stories_title ||= Storybook.stories_title_generator.call(self)
100
+ end
101
+
102
+ def parameters_collection
103
+ @parameters_collection ||= Collections::ParametersCollection.new
104
+ end
105
+
106
+ def layout_collection
107
+ @layout_collection ||= Collections::LayoutCollection.new
108
+ end
109
+
110
+ def story_names
111
+ @story_names ||= public_instance_methods(false)
112
+ end
113
+
114
+ def story_id(name)
115
+ "#{stories_name}/#{name.to_s.parameterize}".underscore
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yard"
4
+
5
+ module ViewComponent
6
+ module Storybook
7
+ class StoriesParser
8
+ def initialize(paths)
9
+ @paths = paths
10
+ @after_parse_callbacks = []
11
+ @after_parse_once_callbacks = []
12
+ @parsing = false
13
+
14
+ YARD::Parser::SourceParser.after_parse_list { run_callbacks }
15
+ end
16
+
17
+ def parse(&block)
18
+ return if @parsing
19
+
20
+ @parsing = true
21
+ @after_parse_once_callbacks << block if block
22
+ YARD::Registry.clear
23
+ YARD.parse(paths)
24
+ end
25
+
26
+ def after_parse(&block)
27
+ @after_parse_callbacks << block
28
+ end
29
+
30
+ attr_reader :paths
31
+
32
+ protected
33
+
34
+ def callbacks
35
+ [
36
+ *@after_parse_callbacks,
37
+ *@after_parse_once_callbacks
38
+ ]
39
+ end
40
+
41
+ def run_callbacks
42
+ callbacks.each { |cb| cb.call(YARD::Registry) }
43
+ @after_parse_once_callbacks = []
44
+ @parsing = false
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Storybook
5
+ class Story
6
+ attr_reader :id, :name, :parameters, :controls
7
+
8
+ def initialize(id, name, parameters, controls)
9
+ @id = id
10
+ @name = name
11
+ @parameters = parameters
12
+ @controls = controls
13
+ end
14
+
15
+ def to_csf_params
16
+ csf_params = { name: name, parameters: { server: { id: id } } }
17
+ csf_params.deep_merge!(parameters: parameters) if parameters.present?
18
+ controls.each do |control|
19
+ csf_params.deep_merge!(control.to_csf_params)
20
+ end
21
+ csf_params
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :view_component_storybook do
4
+ desc "Write CSF JSON stories for all Stories"
5
+ task write_stories_json: :environment do
6
+ puts "Writing Stories JSON"
7
+ exceptions = []
8
+ ViewComponent::Storybook::Engine.stories.each do |stories|
9
+ json_path = stories.write_csf_json
10
+ puts "#{stories.name} => #{json_path}"
11
+ rescue StandardError => e
12
+ exceptions << e
13
+ end
14
+
15
+ raise StandardError, exceptions.map(&:message).join(", ") if exceptions.present?
16
+
17
+ puts "Done"
18
+ end
19
+
20
+ desc "Remove all existing CSF JSON Stories"
21
+ task remove_stories_json: :environment do
22
+ puts "Removing old Stories JSON"
23
+ exceptions = []
24
+ Dir["#{ViewComponent::Storybook.stories_path}/**/*.stories.json"].sort.each do |file|
25
+ puts file
26
+ File.unlink(file)
27
+ rescue StandardError => e
28
+ exceptions << e
29
+ end
30
+
31
+ raise StandardError, exceptions.map(&:message).join(", ") if exceptions.present?
32
+
33
+ puts "Done"
34
+ end
35
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Storybook
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+ require "active_support/dependencies/autoload"
5
+
6
+ module ViewComponent
7
+ module Storybook
8
+ extend ActiveSupport::Autoload
9
+
10
+ autoload :Controls
11
+ autoload :Collections
12
+ autoload :Stories
13
+ autoload :StoriesParser
14
+
15
+ autoload :Story
16
+ autoload :Slots
17
+
18
+ include ActiveSupport::Configurable
19
+ # Set the location of component previews through app configuration:
20
+ #
21
+ # config.view_component_storybook.stories_path = Rails.root.join("lib/component_stories")
22
+ #
23
+ mattr_accessor :stories_paths, instance_writer: false
24
+
25
+ # Enable or disable component previews through app configuration:
26
+ #
27
+ # config.view_component_storybook.show_stories = true
28
+ #
29
+ # Defaults to +true+ for development environment
30
+ #
31
+ mattr_accessor :show_stories, instance_writer: false
32
+
33
+ # Set the entry route for component stories:
34
+ #
35
+ # config.view_component_storybook.stories_route = "/stories"
36
+ #
37
+ # Defaults to `/rails/stories` when `show_stories` is enabled.
38
+ #
39
+ mattr_accessor :stories_route, instance_writer: false
40
+
41
+ # :nocov:
42
+ if defined?(ViewComponent::Storybook::Engine)
43
+ ActiveSupport::Deprecation.warn(
44
+ "This manually engine loading is deprecated and will be removed in v1.0.0. " \
45
+ "Remove `require \"view_component/storybook/engine\"`."
46
+ )
47
+ elsif defined?(Rails::Engine)
48
+ require "view_component/storybook/engine"
49
+ end
50
+ # :nocov:
51
+
52
+ # Define how component stories titles are generated:
53
+ #
54
+ # config.view_component_storybook.stories_title_generator = lambda { |stories|
55
+ # stories.stories_name.humanize.upcase
56
+ # }
57
+ #
58
+ mattr_accessor :stories_title_generator, instance_writer: false,
59
+ default: ->(stories) { stories.stories_name.humanize.titlecase }
60
+
61
+ ActiveSupport.run_load_hooks(:view_component_storybook, self)
62
+ end
63
+ end