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