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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +21 -0
- data/docs/CHANGELOG.md +69 -0
- data/lib/view_component/storybook/collections/controls_collection.rb +80 -0
- data/lib/view_component/storybook/collections/layout_collection.rb +37 -0
- data/lib/view_component/storybook/collections/parameters_collection.rb +40 -0
- data/lib/view_component/storybook/collections/stories_collection.rb +31 -0
- data/lib/view_component/storybook/collections/valid_for_story_concern.rb +15 -0
- data/lib/view_component/storybook/collections.rb +17 -0
- data/lib/view_component/storybook/controls/base_options.rb +30 -0
- data/lib/view_component/storybook/controls/boolean.rb +30 -0
- data/lib/view_component/storybook/controls/color.rb +26 -0
- data/lib/view_component/storybook/controls/control.rb +34 -0
- data/lib/view_component/storybook/controls/date.rb +32 -0
- data/lib/view_component/storybook/controls/multi_options.rb +44 -0
- data/lib/view_component/storybook/controls/number.rb +38 -0
- data/lib/view_component/storybook/controls/object.rb +28 -0
- data/lib/view_component/storybook/controls/options.rb +36 -0
- data/lib/view_component/storybook/controls/simple_control.rb +47 -0
- data/lib/view_component/storybook/controls/text.rb +13 -0
- data/lib/view_component/storybook/controls.rb +23 -0
- data/lib/view_component/storybook/engine.rb +76 -0
- data/lib/view_component/storybook/stories.rb +120 -0
- data/lib/view_component/storybook/stories_parser.rb +48 -0
- data/lib/view_component/storybook/story.rb +25 -0
- data/lib/view_component/storybook/tasks/view_component_storybook.rake +35 -0
- data/lib/view_component/storybook/version.rb +7 -0
- data/lib/view_component/storybook.rb +63 -0
- 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,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,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
|