view_component-storybook 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.
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