From c05061bd927f8022415c65f2587a4ad6609f3cda Mon Sep 17 00:00:00 2001 From: Jon Palmer <328224+jonspalmer@users.noreply.github.com> Date: Sat, 17 Dec 2022 13:30:10 -0500 Subject: [PATCH 01/35] First pass at v1 API --- Gemfile.lock | 4 + lib/view_component/storybook.rb | 7 +- lib/view_component/storybook/controls.rb | 1 + .../storybook/controls/base_options_config.rb | 4 +- .../storybook/controls/color_config.rb | 4 +- .../storybook/controls/control_config.rb | 11 +- .../storybook/controls/controls_collection.rb | 31 + .../storybook/controls/controls_helpers.rb | 61 ++ .../storybook/controls/date_config.rb | 4 +- .../controls/multi_options_config.rb | 4 +- .../storybook/controls/number_config.rb | 4 +- .../controls/simple_control_config.rb | 6 +- .../storybook/dsl/legacy_controls_dsl.rb | 24 +- lib/view_component/storybook/engine.rb | 37 +- .../storybook/stories_collection.rb | 28 + .../storybook/stories_config.rb | 71 +++ .../storybook/stories_parser.rb | 56 ++ lib/view_component/storybook/stories_v2.rb | 83 +++ lib/view_component/storybook/story_v2.rb | 33 ++ spec/dummy/config/application.rb | 2 + .../stories/args_component_stories.rb | 33 +- .../stories/combined_control_stories.rb | 28 + .../stories/content_component_stories_v2.rb | 40 ++ .../stories/custom_control_stories.rb | 43 -- .../demo/button_component_stories_v2.rb | 25 + .../demo/heading_component_stories_v2.rb | 17 + .../invalid/duplicate_story_stories.rb | 17 - .../invalid/invalid_constructor_stories.rb | 9 - .../kitchen_sink_component_stories_v2.rb | 71 +++ .../stories/kwargs_component_stories.rb | 35 +- .../components/stories/layout_stories_v2.rb | 22 + .../stories/mixed_args_component_stories.rb | 13 +- .../stories/no_layout_stories_v2.rb | 16 + .../stories/parameters_stories_v2.rb | 22 + .../storybook/previews_controller_spec.rb | 264 +++++++++ .../storybook/stories_controller_spec.rb | 2 +- spec/view_component/storybook/stories_spec.rb | 2 +- .../storybook/stories_v2_spec.rb | 539 ++++++++++++++++++ view_component_storybook.gemspec | 1 + 39 files changed, 1531 insertions(+), 143 deletions(-) create mode 100644 lib/view_component/storybook/controls/controls_collection.rb create mode 100644 lib/view_component/storybook/stories_collection.rb create mode 100644 lib/view_component/storybook/stories_config.rb create mode 100644 lib/view_component/storybook/stories_parser.rb create mode 100644 lib/view_component/storybook/stories_v2.rb create mode 100644 lib/view_component/storybook/story_v2.rb create mode 100644 spec/dummy/test/components/stories/combined_control_stories.rb create mode 100644 spec/dummy/test/components/stories/content_component_stories_v2.rb delete mode 100644 spec/dummy/test/components/stories/custom_control_stories.rb create mode 100644 spec/dummy/test/components/stories/demo/button_component_stories_v2.rb create mode 100644 spec/dummy/test/components/stories/demo/heading_component_stories_v2.rb delete mode 100644 spec/dummy/test/components/stories/invalid/duplicate_story_stories.rb delete mode 100644 spec/dummy/test/components/stories/invalid/invalid_constructor_stories.rb create mode 100644 spec/dummy/test/components/stories/kitchen_sink_component_stories_v2.rb create mode 100644 spec/dummy/test/components/stories/layout_stories_v2.rb create mode 100644 spec/dummy/test/components/stories/no_layout_stories_v2.rb create mode 100644 spec/dummy/test/components/stories/parameters_stories_v2.rb create mode 100644 spec/view_component/storybook/previews_controller_spec.rb create mode 100644 spec/view_component/storybook/stories_v2_spec.rb diff --git a/Gemfile.lock b/Gemfile.lock index 1c356d1..3d49395 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,7 @@ PATH specs: view_component_storybook (0.12.1) view_component (>= 2.54) + yard (~> 0.9.25) GEM remote: https://rubygems.org/ @@ -232,11 +233,14 @@ GEM activesupport (>= 5.2.0, < 8.0) concurrent-ruby (~> 1.0) method_source (~> 1.0) + webrick (1.7.0) websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) + yard (0.9.28) + webrick (~> 1.7.0) zeitwerk (2.6.6) PLATFORMS diff --git a/lib/view_component/storybook.rb b/lib/view_component/storybook.rb index b8dfe06..044d4d8 100644 --- a/lib/view_component/storybook.rb +++ b/lib/view_component/storybook.rb @@ -9,8 +9,13 @@ module Storybook autoload :Controls autoload :Stories + autoload :StoriesV2 + autoload :StoriesParser + autoload :StoriesCollection + autoload :StoriesConfig autoload :StoryConfig autoload :Story + autoload :StoryV2 autoload :Slots autoload :ContentConcern autoload :MethodArgs @@ -21,7 +26,7 @@ module Storybook # # config.view_component_storybook.stories_path = Rails.root.join("lib/component_stories") # - mattr_accessor :stories_path, instance_writer: false + mattr_accessor :stories_paths, instance_writer: false # Enable or disable component previews through app configuration: # diff --git a/lib/view_component/storybook/controls.rb b/lib/view_component/storybook/controls.rb index 89963cb..bf42430 100644 --- a/lib/view_component/storybook/controls.rb +++ b/lib/view_component/storybook/controls.rb @@ -20,6 +20,7 @@ module Controls autoload :ObjectConfig autoload :CustomConfig autoload :ControlsHelpers + autoload :ControlsCollection end end end diff --git a/lib/view_component/storybook/controls/base_options_config.rb b/lib/view_component/storybook/controls/base_options_config.rb index 4be86ef..a821e22 100644 --- a/lib/view_component/storybook/controls/base_options_config.rb +++ b/lib/view_component/storybook/controls/base_options_config.rb @@ -8,8 +8,8 @@ class BaseOptionsConfig < SimpleControlConfig validates :type, :options, presence: true - def initialize(type, options, default_value, labels: nil, param: nil, name: nil, description: nil) - super(default_value, param: param, name: name, description: description) + def initialize(type, options, default_value, labels: nil, param: nil, name: nil, description: nil, **opts) + super(default_value, param: param, name: name, description: description, **opts) @type = type @options = options @labels = labels diff --git a/lib/view_component/storybook/controls/color_config.rb b/lib/view_component/storybook/controls/color_config.rb index 6ca8517..ec2dc86 100644 --- a/lib/view_component/storybook/controls/color_config.rb +++ b/lib/view_component/storybook/controls/color_config.rb @@ -6,8 +6,8 @@ module Controls class ColorConfig < SimpleControlConfig attr_reader :preset_colors - def initialize(default_value, preset_colors: nil, param: nil, name: nil, description: nil) - super(default_value, param: param, name: name, description: description) + def initialize(default_value, preset_colors: nil, param: nil, name: nil, description: nil, **opts) + super(default_value, param: param, name: name, description: description, **opts) @preset_colors = preset_colors end diff --git a/lib/view_component/storybook/controls/control_config.rb b/lib/view_component/storybook/controls/control_config.rb index b30540e..95e5fc4 100644 --- a/lib/view_component/storybook/controls/control_config.rb +++ b/lib/view_component/storybook/controls/control_config.rb @@ -8,10 +8,13 @@ class ControlConfig validates :param, presence: true - def initialize(param: nil, name: nil, description: nil) + attr_reader :opts + + def initialize(param: nil, name: nil, description: nil, **opts) @param = param @name = name @description = description + @opts = opts end def name(new_name = nil) @@ -52,6 +55,12 @@ def value_from_params(params) raise NotImplementedError # :nocov: end + + def valid_for_story?(story_name) + # expand to include arrays of names + # expand to include except + opts[:only].nil? || opts[:only] == story_name + end end end end diff --git a/lib/view_component/storybook/controls/controls_collection.rb b/lib/view_component/storybook/controls/controls_collection.rb new file mode 100644 index 0000000..4839d25 --- /dev/null +++ b/lib/view_component/storybook/controls/controls_collection.rb @@ -0,0 +1,31 @@ + +module ViewComponent + module Storybook + module Controls + class ControlsCollection + attr_reader :controls_by_story, :story_default_values + + def initialize(story_methods) + @controls_by_story = story_methods.map {|method| [method.name, {}]}.to_h + + class_string = File.read(story_methods.first.source_location[0]) + # code_object = YARD.parse_string(class_string) + # # puts "code_object: #{code_object}" + # # puts "code_object.meths: #{code_object.meths}" + # puts "YARD::Registry.all: #{YARD::Registry.all(:class)}" + + # parsed = Parser::CurrentRuby.parse(class_string) + # p "Parser: #{parsed}" + # p parsed.methods + + story_methods.each do |method| + puts "method.source_location: #{method.source_location}" + source_location = method.source_location + # File.readlines(source_location[0].each {|line| puts line} + puts "method def: #{File.readlines(source_location[0])[source_location[1]-1]}" + end + end + end + end + end +end \ No newline at end of file diff --git a/lib/view_component/storybook/controls/controls_helpers.rb b/lib/view_component/storybook/controls/controls_helpers.rb index 4b7a066..4ed1b45 100644 --- a/lib/view_component/storybook/controls/controls_helpers.rb +++ b/lib/view_component/storybook/controls/controls_helpers.rb @@ -4,6 +4,67 @@ module ViewComponent module Storybook module Controls module ControlsHelpers + extend ActiveSupport::Concern + + included do + class_attribute :controls + end + + def inherited(other) + super(other) + # setup class defaults + other.controls = [] + end + + class_methods do + def inherited(other) + super(other) + # setup class defaults + other.controls = [] + end + + def control(param, as:, default:, name: nil, description: nil, **opts) + self.controls << case as + when :text + Controls::TextConfig.new(default, param: param, name: name, description: description, **opts) + when :boolean + Controls::BooleanConfig.new(default, param: param, name: name, description: description, **opts) + when :number + Controls::NumberConfig.new(:number, default, param: param, name: name, description: description, **opts) + when :range + Controls::NumberConfig.new(:range, default, param: param, name: name, description: description, **opts) + when :color + Controls::ColorConfig.new(default, param: param, name: name, description: description, **opts) + when :object + Controls::ObjectConfig.new(default, param: param, name: name, description: description, **opts) + when :select + options = opts.delete(:options) + Controls::OptionsConfig.new(:select, options, default, param: param, name: name, description: description, **opts) + when :multi_select + options = opts.delete(:options) + Controls::MultiOptionsConfig.new(:'multi-select', options, default, param: param, name: name, description: description, **opts) + when :radio + options = opts.delete(:options) + Controls::OptionsConfig.new(:radio, options, default, param: param, name: name, description: description, **opts) + when :inline_radio + options = opts.delete(:options) + Controls::MultiOptionsConfigptionsConfig.new(:'inline-radio', options, default, param: param, name: name, description: description, **opts) + when :check + options = opts.delete(:options) + Controls::MultiOptionsConfig.new(:check, options, default, param: param, name: name, description: description, **opts) + when :inline_check + options = opts.delete(:options) + Controls::MultiOptionsConfig.new(:'inline-check', options, default, param: param, name: name, description: description, **opts) + when :date + Controls::DateConfig.new(default, param: param, name: name, description: description, **opts) + when :array + Controls::ObjectConfig.new(default, param: param, name: name, description: description, **opts) + else + raise "Unknonwn control type '#{as}'" + end + end + end + def text(default_value) Controls::TextConfig.new(default_value) end diff --git a/lib/view_component/storybook/controls/date_config.rb b/lib/view_component/storybook/controls/date_config.rb index 9a40710..b57bfee 100644 --- a/lib/view_component/storybook/controls/date_config.rb +++ b/lib/view_component/storybook/controls/date_config.rb @@ -4,8 +4,8 @@ module ViewComponent module Storybook module Controls class DateConfig < SimpleControlConfig - def initialize(default_value, param: nil, name: nil, description: nil) - super(default_value, param: param, name: name, description: description) + def initialize(default_value, param: nil, name: nil, description: nil, **opts) + super(default_value, param: param, name: name, description: description, **opts) end def type diff --git a/lib/view_component/storybook/controls/multi_options_config.rb b/lib/view_component/storybook/controls/multi_options_config.rb index 4e4d2cc..ba4ac9a 100644 --- a/lib/view_component/storybook/controls/multi_options_config.rb +++ b/lib/view_component/storybook/controls/multi_options_config.rb @@ -9,8 +9,8 @@ class MultiOptionsConfig < BaseOptionsConfig validates :type, inclusion: { in: TYPES }, unless: -> { type.nil? } validate :validate_default_value, unless: -> { options.nil? || default_value.nil? } - def initialize(type, options, default_value, labels: nil, param: nil, name: nil, description: nil) - super(type, options, Array.wrap(default_value), labels: labels, param: param, name: name, description: description) + def initialize(type, options, default_value, labels: nil, param: nil, name: nil, description: nil, **opts) + super(type, options, Array.wrap(default_value), labels: labels, param: param, name: name, description: description, **opts) end def value_from_params(params) diff --git a/lib/view_component/storybook/controls/number_config.rb b/lib/view_component/storybook/controls/number_config.rb index 2960c00..748b2c6 100644 --- a/lib/view_component/storybook/controls/number_config.rb +++ b/lib/view_component/storybook/controls/number_config.rb @@ -11,8 +11,8 @@ class NumberConfig < SimpleControlConfig validates :type, presence: true validates :type, inclusion: { in: TYPES }, unless: -> { type.nil? } - def initialize(type, default_value, min: nil, max: nil, step: nil, param: nil, name: nil, description: nil) - super(default_value, param: param, name: name, description: description) + def initialize(type, default_value, min: nil, max: nil, step: nil, param: nil, name: nil, description: nil, **opts) + super(default_value, param: param, name: name, description: description, **opts) @type = type @min = min @max = max diff --git a/lib/view_component/storybook/controls/simple_control_config.rb b/lib/view_component/storybook/controls/simple_control_config.rb index 7b2117d..297a493 100644 --- a/lib/view_component/storybook/controls/simple_control_config.rb +++ b/lib/view_component/storybook/controls/simple_control_config.rb @@ -7,10 +7,10 @@ module Controls # A simple Control Config maps to one Storybook Control # It has a value and pulls its value from params by key class SimpleControlConfig < ControlConfig - attr_reader :default_value + attr_accessor :default_value - def initialize(default_value, param: nil, name: nil, description: nil) - super(param: param, name: name, description: description) + def initialize(default_value, param: nil, name: nil, description: nil, **opts) + super(param: param, name: name, description: description, **opts) @default_value = default_value end diff --git a/lib/view_component/storybook/dsl/legacy_controls_dsl.rb b/lib/view_component/storybook/dsl/legacy_controls_dsl.rb index 3e218c0..bb6a8f9 100644 --- a/lib/view_component/storybook/dsl/legacy_controls_dsl.rb +++ b/lib/view_component/storybook/dsl/legacy_controls_dsl.rb @@ -32,28 +32,28 @@ def object(param, value, name: nil) controls << Controls::ObjectConfig.new(value, param: param, name: name) end - def select(param, options, value, name: nil) - controls << Controls::OptionsConfig.new(:select, options, value, param: param, name: name) + def select(param, options, value, labels: nil, name: nil) + controls << Controls::OptionsConfig.new(:select, options, value, param: param, name: name, labels: labels) end - def multi_select(param, options, value, name: nil) - controls << Controls::OptionsConfig.new(:'multi-select', options, value, param: param, name: name) + def multi_select(param, options, value, labels: nil, name: nil) + controls << Controls::OptionsConfig.new(:'multi-select', options, value, param: param, name: name, labels: labels) end - def radio(param, options, value, name: nil) - controls << Controls::OptionsConfig.new(:radio, options, value, param: param, name: name) + def radio(param, options, value, labels: nil, name: nil) + controls << Controls::OptionsConfig.new(:radio, options, value, param: param, name: name, labels: labels) end - def inline_radio(param, options, value, name: nil) - controls << Controls::OptionsConfig.new(:'inline-radio', options, value, param: param, name: name) + def inline_radio(param, options, value, labels: nil, name: nil) + controls << Controls::OptionsConfig.new(:'inline-radio', options, value, param: param, name: name, labels: labels) end - def check(param, options, value, name: nil) - controls << Controls::OptionsConfig.new(:check, options, value, param: param, name: name) + def check(param, options, value, labels: nil, name: nil) + controls << Controls::OptionsConfig.new(:check, options, value, param: param, name: name, labels: labels) end - def inline_check(param, options, value, name: nil) - controls << Controls::OptionsConfig.new(:'inline-check', options, value, param: param, name: name) + def inline_check(param, options, value, labels: nil, name: nil) + controls << Controls::OptionsConfig.new(:'inline-check', options, value, param: param, name: name, labels: labels) end def array(param, value, _separator = nil, name: nil) diff --git a/lib/view_component/storybook/engine.rb b/lib/view_component/storybook/engine.rb index f1c13b7..f6c7b3f 100644 --- a/lib/view_component/storybook/engine.rb +++ b/lib/view_component/storybook/engine.rb @@ -6,6 +6,7 @@ module ViewComponent module Storybook class Engine < Rails::Engine config.view_component_storybook = ActiveSupport::OrderedOptions.new + config.view_component_storybook.stories_paths ||= [] initializer "view_component_storybook.set_configs" do |app| options = app.config.view_component_storybook @@ -14,7 +15,11 @@ class Engine < Rails::Engine options.stories_route ||= "/rails/stories" if options.show_stories - options.stories_path ||= defined?(Rails.root) ? Rails.root.join("test/components/stories").to_s : nil + options.stories_paths << Rails.root.join("test/components/stories").to_s if defined?(Rails.root) && Dir.exist?( + "#{Rails.root}/test/components/stories" + ) + + end options.stories_title_generator ||= ViewComponent::Storybook.stories_title_generator @@ -24,13 +29,19 @@ class Engine < Rails::Engine end end - initializer "view_component.set_autoload_paths" do |app| + initializer "view_component_storybook.set_autoload_paths" do |app| options = app.config.view_component_storybook - if options.show_stories && - options.stories_path && - ActiveSupport::Dependencies.autoload_paths.exclude?(options.stories_path) - ActiveSupport::Dependencies.autoload_paths << options.stories_path + if options.show_stories && !options.stories_paths.empty? + paths_to_add = options.stories_paths - ActiveSupport::Dependencies.autoload_paths + ActiveSupport::Dependencies.autoload_paths.concat(paths_to_add) if paths_to_add.any? + end + end + + initializer "view_component_storybook.parser.stories_load_callback" do + + parser.after_parse do |code_objects| + Engine.stories.load(code_objects.all(:class)) end end @@ -44,9 +55,23 @@ class Engine < Rails::Engine end end + config.after_initialize do + parser.parse + end + rake_tasks do load File.join(__dir__, "tasks/view_component_storybook.rake") end + + def parser + @_parser ||= StoriesParser.new(ViewComponent::Storybook.stories_paths) #, Engine.tags) + end + + class << self + def stories + @stories ||= StoriesCollection.new + end + end end end end diff --git a/lib/view_component/storybook/stories_collection.rb b/lib/view_component/storybook/stories_collection.rb new file mode 100644 index 0000000..86e689b --- /dev/null +++ b/lib/view_component/storybook/stories_collection.rb @@ -0,0 +1,28 @@ +module ViewComponent + module Storybook + class StoriesCollection + + attr_reader :stories + + def load(code_objects) + @stories = Array(code_objects).map { |obj| StoriesCollection.stories_config_from_code_object(obj) }.compact + end + + def self.stories_config_from_code_object(code_object) + klass = code_object.path.constantize + + ViewComponent::Storybook::StoriesConfig.new(code_object) if stories_class?(klass) + # rescue => exception + # puts exception.to_s + # # Lookbook.logger.error exception.to_s + # nil + end + + def self.stories_class?(klass) + if klass.ancestors.include?(ViewComponent::Storybook::StoriesV2) + !klass.respond_to?(:abstract_class) || klass.abstract_class != true + end + end + end + end +end diff --git a/lib/view_component/storybook/stories_config.rb b/lib/view_component/storybook/stories_config.rb new file mode 100644 index 0000000..a308922 --- /dev/null +++ b/lib/view_component/storybook/stories_config.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module ViewComponent + module Storybook + class StoriesConfig + + delegate :title, :parameters, :stories_name, to: :stories_class + attr_reader :story_configs, :stories_class, :stories_json_path + + + def initialize(code_object) + @code_object = code_object + @stories_class = code_object.path.constantize + + dir = File.dirname(@code_object.file) + json_filename = code_object.path.demodulize.underscore + + @stories_json_path = File.join(dir, "#{json_filename}.stories.json") + + @stories_class.stories_config = self + end + + + # def story_configs + # @story_configs = + # end + + + def to_csf_params + csf_params = { title: title } + csf_params[:parameters] = parameters if parameters.present? + csf_params[:stories] = story_configs.map(&:to_csf_params) + csf_params + end + + def write_csf_json + # json_path = File.join(stories_path, "#{stories_name}.stories.json") + File.write(stories_json_path, JSON.pretty_generate(to_csf_params)) + stories_json_path + end + + def story_configs + @story_configs ||= begin + public_methods = stories_class.public_instance_methods(false) + method_objects = @code_object.meths.select { |m| public_methods.include?(m.name) } + method_objects.map { |code_object| StoryV2.from_code_object(code_object, self) } + end + end + + # # Returns +true+ if the stories exist. + # def stories_exists?(stories_name) + # all.any? { |stories| stories.stories_name == stories_name } + # end + + # # Find a component stories by its underscored class name. + # def find_story_configs(stories_name) + # all.find { |stories| stories.stories_name == stories_name } + # end + + # # Returns +true+ if the story of the component stories exists. + # def story_exists?(name) + # story_configs.map(&:name).include?(name.to_sym) + # end + + # # find the story by name + # def find_story_config(name) + # story_configs.find { |config| config.name == name.to_sym } + # end + end + end +end diff --git a/lib/view_component/storybook/stories_parser.rb b/lib/view_component/storybook/stories_parser.rb new file mode 100644 index 0000000..a171db2 --- /dev/null +++ b/lib/view_component/storybook/stories_parser.rb @@ -0,0 +1,56 @@ +require "yard" + +module ViewComponent + module Storybook + class StoriesParser + def initialize(paths, tags = nil) + @paths = paths + @after_parse_callbacks = [] + @after_parse_once_callbacks = [] + @parsing = false + + define_tags(tags) + YARD::Parser::SourceParser.after_parse_list { run_callbacks } + end + + def parse(&block) + unless @parsing + @parsing = true + @after_parse_once_callbacks << block if block + YARD::Registry.clear + YARD.parse(paths) + end + end + + def after_parse(&block) + @after_parse_callbacks << block + end + + def paths + @paths + # PathUtils.normalize_paths(@paths).map { |path| "#{path}/**/*preview.rb" } + end + + protected + + def callbacks + [ + *@after_parse_callbacks, + *@after_parse_once_callbacks + ] + end + + def run_callbacks + callbacks.each { |cb| cb.call(YARD::Registry) } + @after_parse_once_callbacks = [] + @parsing = false + end + + def define_tags(tags = nil) + # tags.to_h.each do |name, tag| + # YARD::Tags::Library.define_tag(tag[:label], name, Lookbook::TagProvider) + # end + end + end + end +end \ No newline at end of file diff --git a/lib/view_component/storybook/stories_v2.rb b/lib/view_component/storybook/stories_v2.rb new file mode 100644 index 0000000..7001de6 --- /dev/null +++ b/lib/view_component/storybook/stories_v2.rb @@ -0,0 +1,83 @@ +require "yard" + +module ViewComponent + module Storybook + class StoriesV2 < ViewComponent::Preview + include Controls::ControlsHelpers + + class_attribute :stories_parameters, :stories_title, :stories_config + + class << self + + def title(title = nil) + # if no argument is passed act like a getter + self.stories_title = title unless title.nil? + stories_title + end + + def parameters(params = nil) + # if no argument is passed act like a getter + self.stories_parameters = params unless params.nil? + stories_parameters + end + + def stories_name + name.chomp("V2").chomp("Stories").underscore + end + + def preview_name + stories_name + end + + def to_csf_params + csf_params = { title: title } + csf_params[:parameters] = parameters if parameters.present? + csf_params[:stories] = story_configs.map(&:to_csf_params) + csf_params + end + + def write_csf_json + # json_path = File.join(stories_path, "#{stories_name}.stories.json") + File.write(stories_json_path, JSON.pretty_generate(to_csf_params)) + stories_json_path + end + + def story_configs + @story_configs ||= begin + story_names.map { |method| StoryV2.new(story_id(method), method, {}, controls_for_story(method)) } + end + end + + private + + def inherited(other) + super(other) + # setup class defaults + other.stories_title = Storybook.stories_title_generator.call(other) + end + + def stories_json_path + @stories_json_path ||= File.join(File.dirname(__FILE__), "#{File.basename(__FILE__, ".rb")}.stories.json") + end + + def story_id(name) + "#{stories_name}/#{name.to_s.parameterize}".underscore + end + + def story_methods + @story_methods ||= public_instance_methods(false).map { |name| instance_method(name) } + end + + def story_names + @story_names ||= story_methods.map(&:name) + end + + def controls_for_story(story_name) + controls.select do |control| + control.valid_for_story?(story_name) + end + end + end + end + end +end \ No newline at end of file diff --git a/lib/view_component/storybook/story_v2.rb b/lib/view_component/storybook/story_v2.rb new file mode 100644 index 0000000..44e0106 --- /dev/null +++ b/lib/view_component/storybook/story_v2.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module ViewComponent + module Storybook + class StoryV2 + + attr_reader :id, :name, :parameters, :controls + + def initialize(id, name, parameters, controls) + @id = id + @name = name + @parameters = parameters + @controls = controls + end + + def to_csf_params + csf_params = { name: name, parameters: { server: { id: id } } } + csf_params.deep_merge!(parameters: parameters) if parameters.present? + controls.each do |control| + csf_params.deep_merge!(control.to_csf_params) + end + csf_params + end + + def self.from_code_object(code_object, stories_config) + name = code_object.name + id = "#{stories_config.stories_name}/#{name.to_s.parameterize}".underscore + + self.new(id, name, {}, []) + end + end + end +end diff --git a/spec/dummy/config/application.rb b/spec/dummy/config/application.rb index e41ab82..6695613 100644 --- a/spec/dummy/config/application.rb +++ b/spec/dummy/config/application.rb @@ -13,5 +13,7 @@ module Dummy class Application < Rails::Application config.secret_key_base = "foo" + + # config.view_component.preview_paths << Rails.root.join("test/components/stories") end end diff --git a/spec/dummy/test/components/stories/args_component_stories.rb b/spec/dummy/test/components/stories/args_component_stories.rb index a19f2c7..3bfe5f3 100644 --- a/spec/dummy/test/components/stories/args_component_stories.rb +++ b/spec/dummy/test/components/stories/args_component_stories.rb @@ -1,24 +1,25 @@ # frozen_string_literal: true -class ArgsComponentStories < ViewComponent::Storybook::Stories - story :default do - constructor( - text("Hello World!"), - text("How you doing?") - ) +class ArgsComponentStories < ViewComponent::Storybook::StoriesV2 + + control :items0, as: :text, default: "Hello World!", only: :default + control :items1, as: :text, default: "How you doing?", only: :default + + def default(items0: "Hello World!", items1: "How you doing?") + render ArgsComponent.new(items0, items1) end - story :fixed_args do - constructor( - text("Hello World!"), - "How you doing?" - ) + + control :items0, as: :text, default: "Hello World!", only: :fixed_args + + def fixed_args(items0: "Hello World!") + render ArgsComponent.new(items0, "How you doing?") end - story :custom_param do - constructor( - text("Hello World!").param(:message), - text("How you doing?") - ) + control :message, as: :text, default: "Hello World!", only: :custom_param + control :items1, as: :text, default: "How you doing?", only: :custom_param + + def custom_param(message: "Hello World!", items1: "How you doing?") + render ArgsComponent.new(message, items1) end end diff --git a/spec/dummy/test/components/stories/combined_control_stories.rb b/spec/dummy/test/components/stories/combined_control_stories.rb new file mode 100644 index 0000000..db182b9 --- /dev/null +++ b/spec/dummy/test/components/stories/combined_control_stories.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class CombinedControlStories < ViewComponent::Storybook::StoriesV2 + + control :greeting, as: :text, default: "Hi", only: :combined_text + control :name, as: :text, default: "Sarah", only: :combined_text + + def combined_text(greeting: "Hi!", name: "Sarah") + render Demo::ButtonComponent.new(button_text: "#{greeting} #{name}") + end + + + control :verb_one, as: :text, default: "Big", only: :custom_rest_args + control :noun_one, as: :text, default: "Car", only: :custom_rest_args + control :verb_two, as: :text, default: "Small", only: :custom_rest_args + control :noun_tow, as: :text, default: "Boat", only: :custom_rest_args + + def custom_rest_args(verb_one: "Big", noun_one: "Car", verb_two: "Small", noun_two: "Boat") + render ArgsComponent.new("#{verb_one} #{noun_one}", "#{verb_two} #{noun_two}") + end + + + control :greeting, as: :text, default: "DO NOT PUSH!", description: "Make this irresistible.", only: :described_control + + def described_control(button_text:'DO NOT PUSH!' ) + render Demo::ButtonComponent.new(button_text: button_text) + end +end diff --git a/spec/dummy/test/components/stories/content_component_stories_v2.rb b/spec/dummy/test/components/stories/content_component_stories_v2.rb new file mode 100644 index 0000000..a1ca79e --- /dev/null +++ b/spec/dummy/test/components/stories/content_component_stories_v2.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class ContentComponentStoriesV2 < ViewComponent::Storybook::StoriesV2 + + def with_string_content + render(ContentComponent.new) do + "Hello World!" + end + end + + # controls(only: :with_described_control) do + # text(:content, "Hello World") + # end + + control :content, as: :text, default: "Hello World!", only: :with_control_content + + def with_control_content(content: "Hello World!") + render(ContentComponent.new) do + content + end + end + + # controls(only: :with_described_control) do + # text(:content, "Hello World", description: "My first computer program.") + # end + + control :content, as: :text, default: "Hello World!", description: "My first computer program.", only: :with_described_control + + def with_described_control(content: "Hello World!") + render(ContentComponent.new) do + content + end + end + + def with_helper_content + render(ContentComponent.new) do + content_tag(:span, "Hello World!") + end + end +end diff --git a/spec/dummy/test/components/stories/custom_control_stories.rb b/spec/dummy/test/components/stories/custom_control_stories.rb deleted file mode 100644 index 1690eb3..0000000 --- a/spec/dummy/test/components/stories/custom_control_stories.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -class CustomControlStories < ViewComponent::Storybook::Stories - story :custom_text, Demo::ButtonComponent do - custom_control = custom(greeting: text("Hi"), name: text("Sarah")) do |greeting:, name:| - "#{greeting} #{name}" - end - constructor( - button_text: custom_control - ) - end - - story :custom_rest_args, ArgsComponent do - custom_control_one = custom(verb: text("Big"), noun: text("Car")) do |verb:, noun:| - "#{verb} #{noun}" - end - custom_control_two = custom(verb: text("Small"), noun: text("Boat")) do |verb:, noun:| - "#{verb} #{noun}" - end - constructor( - custom_control_one, - custom_control_two - ) - end - - story :nested_custom_controls, Demo::ButtonComponent do - name_control = custom(first_name: text("Sarah"), last_name: text("Connor")) do |first_name:, last_name:| - "#{first_name} #{last_name}" - end - custom_control = custom(greeting: text("Hi"), name: name_control) do |greeting:, name:| - "#{greeting} #{name}" - end - constructor( - button_text: custom_control - ) - end - - story :described_control, Demo::ButtonComponent do - constructor( - button_text: text('DO NOT PUSH!').description('Make this irresistible.') - ) - end -end diff --git a/spec/dummy/test/components/stories/demo/button_component_stories_v2.rb b/spec/dummy/test/components/stories/demo/button_component_stories_v2.rb new file mode 100644 index 0000000..f253397 --- /dev/null +++ b/spec/dummy/test/components/stories/demo/button_component_stories_v2.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Demo + class ButtonComponentStoriesV2 < ViewComponent::Storybook::StoriesV2 + + control :button_text, as: :text, default: "OK", only: :short_button + + def short_button(button_text: "OK") + render ButtonComponent.new(button_text: button_text) + end + + + control :button_text, as: :text, default: "Push Me!", only: :medium_button + + def medium_button(button_text: "Push Me!") + render ButtonComponent.new(button_text: button_text) + end + + control :button_text, as: :text, default: "Really Really Long Button Text", only: :long_button + + def long_button(button_text: "Really Really Long Button Text") + render ButtonComponent.new(button_text: button_text) + end + end +end diff --git a/spec/dummy/test/components/stories/demo/heading_component_stories_v2.rb b/spec/dummy/test/components/stories/demo/heading_component_stories_v2.rb new file mode 100644 index 0000000..54733b7 --- /dev/null +++ b/spec/dummy/test/components/stories/demo/heading_component_stories_v2.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Demo + class HeadingComponentStoriesV2 < ViewComponent::Storybook::StoriesV2 + title 'Heading Component' + + # controls do + # text(:heading_text, "Heading") + # end + + control :heading_text, as: :text, default: "Heading" + + def default(heading_text: "Heading") + render HeadingComponent.new(heading_text: button_text) + end + end +end diff --git a/spec/dummy/test/components/stories/invalid/duplicate_story_stories.rb b/spec/dummy/test/components/stories/invalid/duplicate_story_stories.rb deleted file mode 100644 index 02f6216..0000000 --- a/spec/dummy/test/components/stories/invalid/duplicate_story_stories.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module Invalid - class DuplicateStoryStories < ViewComponent::Storybook::Stories - story :my_story, ExampleComponent do - constructor( - title: text("OK") - ) - end - - story :my_story, ExampleComponent do - constructor( - title: text("Not OK!") - ) - end - end -end diff --git a/spec/dummy/test/components/stories/invalid/invalid_constructor_stories.rb b/spec/dummy/test/components/stories/invalid/invalid_constructor_stories.rb deleted file mode 100644 index 81f4952..0000000 --- a/spec/dummy/test/components/stories/invalid/invalid_constructor_stories.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module Invalid - class InvalidConstructorStories < ViewComponent::Storybook::Stories - story :invalid_kwards, ExampleComponent do - constructor(title: "OK", junk: "Not OK!") - end - end -end diff --git a/spec/dummy/test/components/stories/kitchen_sink_component_stories_v2.rb b/spec/dummy/test/components/stories/kitchen_sink_component_stories_v2.rb new file mode 100644 index 0000000..258b256 --- /dev/null +++ b/spec/dummy/test/components/stories/kitchen_sink_component_stories_v2.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +class KitchenSinkComponentStoriesV2 < ViewComponent::Storybook::StoriesV2 + # story :jane_doe, KitchenSinkComponent do + # constructor( + # name: text("Jane Doe"), + # birthday: date(Date.new(1950, 3, 26)), + # favorite_color: color("red"), + # like_people: boolean(true), + # number_pets: number(2), + # sports: array(%w[football baseball]), + # favorite_food: select(["Burgers", "Hot Dog", "Ice Cream", "Pizza"], "Ice Cream"), + # mood: radio( + # [:happy, :sad, :angry, :content], + # :happy, + # labels: { happy: "Happy", sad: "Sad", angry: "Angry", content: "Content" } + # ), + # other_things: object({ hair: "Brown", eyes: "Blue" }) + # ) + # end + + + # controls do + # text(:name, "Jane Doe") + # date(:birthday, Date.new(1950, 3, 26)) + # color(:favorite_color, "red") + # boolean(:like_people, true) + # number(:number_pets, 2) + # array(:sports, %w[football baseball]) + # select(:favorite_food, ["Burgers", "Hot Dog", "Ice Cream", "Pizza"], "Ice Cream") + # radio(:mood, [:happy, :sad, :angry, :content], + # :happy, + # labels: { happy: "Happy", sad: "Sad", angry: "Angry", content: "Content" }) + # object(:other_things, { hair: "Brown", eyes: "Blue" }) + # end + + control :name, as: :text, default: "Jane Doe" + control :birthday, as: :date, default: Date.new(1950, 3, 26) + control :favorite_color, as: :color, default: "red" + control :like_people, as: :boolean, default: true + control :number_pets, as: :number, default: 2 + control :sports, as: :array, default: %w[football baseball] + control :favorite_food, as: :select, default: "Ice Cream", options: ["Burgers", "Hot Dog", "Ice Cream", "Pizza"] + control :mood, as: :radio, default: :happy, options: [:happy, :sad, :angry, :content], labels: { happy: "Happy", sad: "Sad", angry: "Angry", content: "Content" } + control :other_things, as: :object, default: { hair: "Brown", eyes: "Blue" } + + + def jane_doe( + name: "Jane Doe", + birthday: Date.new(1950, 3, 26), + favorite_color: "red", + like_people: true, + number_pets: 2, + sports: %w[football baseball], + favorite_food: "Ice Cream", + mood: :happy, + other_things: { hair: "Brown", eyes: "Blue" } + ) + render KitchenSinkComponent.new( + name: name, + birthday:birthday, + favorite_color:favorite_color, + like_people: like_people, + number_pets: number_pets, + sports: sports, + favorite_food: favorite_food, + mood: mood, + other_things: other_things + ) + end +end diff --git a/spec/dummy/test/components/stories/kwargs_component_stories.rb b/spec/dummy/test/components/stories/kwargs_component_stories.rb index 47b4db9..40e27c5 100644 --- a/spec/dummy/test/components/stories/kwargs_component_stories.rb +++ b/spec/dummy/test/components/stories/kwargs_component_stories.rb @@ -1,26 +1,25 @@ # frozen_string_literal: true -class KwargsComponentStories < ViewComponent::Storybook::Stories - story :default do - constructor( - message: text("Hello World!"), - param: number(1), - other_param: boolean(true) - ) +class KwargsComponentStories < ViewComponent::Storybook::StoriesV2 + + control :message, as: :text, default: "Hello World!", only: :default + control :param, as: :number, default: 1, only: :default + control :other_param, as: :boolean, default: true, only: :default + + def default(message: "Hello World!", param: 1, other_param: true) + render KwargsComponent.new(message: message, param: param, other_param: other_param) end - story :fixed_args do - constructor( - message: text("Hello World!"), - param: 1, - other_param: true - ) + + control :message, as: :text, default: "Hello World!", only: :fixed_args + def fixed_args(message: "Hello World!") + render KwargsComponent.new(message: message, param: 1, other_param: true) end - story :custom_param do - constructor( - message: text("Hello World!").param(:my_message), - param: number(1) - ) + control :my_message, as: :text, default: "Hello World!", only: :custom_param + control :param, as: :number, default: 1, only: :custom_param + + def custom_param(my_message: "Hello World!", param: 1) + render KwargsComponent.new(message: my_message, param: param) end end diff --git a/spec/dummy/test/components/stories/layout_stories_v2.rb b/spec/dummy/test/components/stories/layout_stories_v2.rb new file mode 100644 index 0000000..e7351f2 --- /dev/null +++ b/spec/dummy/test/components/stories/layout_stories_v2.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class LayoutStoriesV2 < ViewComponent::Storybook::StoriesV2 + layout "admin" + + # @control button_text text + def default(button_text: "OK") + render Demo::ButtonComponent.new(button_text: button_text) + end + + # # @control button_text text + # # @layout mobile + # def mobile_layout(button_text: "OK") + # render Demo::ButtonComponent.new(button_text: button_text) + # end + + # # @control button_text text + # # @layout false + # def no_layout(button_text: "OK") + # render Demo::ButtonComponent.new(button_text: button_text) + # end +end diff --git a/spec/dummy/test/components/stories/mixed_args_component_stories.rb b/spec/dummy/test/components/stories/mixed_args_component_stories.rb index fc438cf..2298d4c 100644 --- a/spec/dummy/test/components/stories/mixed_args_component_stories.rb +++ b/spec/dummy/test/components/stories/mixed_args_component_stories.rb @@ -1,11 +1,14 @@ # frozen_string_literal: true -class MixedArgsComponentStories < ViewComponent::Storybook::Stories - story :default do - constructor(text("Hello World!"), message: text("How you doing?")) +class MixedArgsComponentStories < ViewComponent::Storybook::StoriesV2 + control :title, as: :text, default: "Hello World!", only: :default + control :message, as: :text, default: "How you doing?", only: :default + + def default(title: "Hello World!", message: "How you doing?") + render MixedArgsComponent.new(title, message: message) end - story :fixed_args do - constructor("Hello World!", message: "How you doing?") + def fixed_args() + render MixedArgsComponent.new("Hello World!", message: "How you doing?") end end diff --git a/spec/dummy/test/components/stories/no_layout_stories_v2.rb b/spec/dummy/test/components/stories/no_layout_stories_v2.rb new file mode 100644 index 0000000..bca7204 --- /dev/null +++ b/spec/dummy/test/components/stories/no_layout_stories_v2.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class NoLayoutStoriesV2 < ViewComponent::Storybook::StoriesV2 + layout false + + # @control button_text text + def default(button_text: "OK") + render Demo::ButtonComponent.new(button_text: button_text) + end + + # # @control button_text text + # # @layout mobile + # def mobile_layout(button_text: "OK") + # render Demo::ButtonComponent.new(button_text: button_text) + # end +end diff --git a/spec/dummy/test/components/stories/parameters_stories_v2.rb b/spec/dummy/test/components/stories/parameters_stories_v2.rb new file mode 100644 index 0000000..d45b2f1 --- /dev/null +++ b/spec/dummy/test/components/stories/parameters_stories_v2.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class ParametersStoriesV2 < ViewComponent::Storybook::StoriesV2 + parameters( size: :small ) + + # @control button_text text + def stories_parameters(button_text: "OK") + render Demo::ButtonComponent.new(button_text: button_text) + end + + # @control button_text text + # @parameters {size: :large, color: :red} + def stories_parameters(button_text: "OK") + render Demo::ButtonComponent.new(button_text: button_text) + end + + # @control button_text text + # @parameters {color: :red} + def additional_parameters(button_text: "OK") + render Demo::ButtonComponent.new(button_text: button_text) + end +end diff --git a/spec/view_component/storybook/previews_controller_spec.rb b/spec/view_component/storybook/previews_controller_spec.rb new file mode 100644 index 0000000..5dfceeb --- /dev/null +++ b/spec/view_component/storybook/previews_controller_spec.rb @@ -0,0 +1,264 @@ +# frozen_string_literal: true + +RSpec.describe ViewComponent::Storybook::StoriesController, type: :request do + it "returns ok" do + get "/rails/view_components/content_component/with_string_content" + + expect(response).to have_http_status(:ok) + end + + it "returns ok for stories with namespaces" do + get "/rails/view_components/demo/button_component/short_button" + + expect(response).to have_http_status(:ok) + end + + it "renders the compoent" do + get "/rails/view_components/demo/button_component/short_button" + + expect(response.body).to have_button(text: "OK") + end + + it "renders a compoent with positional args" do + get "/rails/view_components/args_component/default" + + expect(response.body).to have_selector("p", text: "Hello World!") + expect(response.body).to have_selector("p", text: "How you doing?") + end + + it "renders a compoent with fixed positional args" do + get "/rails/view_components/args_component/fixed_args" + + expect(response.body).to have_selector("p", text: "Hello World!") + expect(response.body).to have_selector("p", text: "How you doing?") + end + + it "renders a compoent with keyword args" do + get "/rails/view_components/kwargs_component/default" + + expect(response.body).to have_selector("h1", text: "Hello World!") + end + + it "renders a compoent with positional and keyword args" do + get "/rails/view_components/mixed_args_component/default" + + expect(response.body).to have_selector("h1", text: "Hello World!") + expect(response.body).to have_selector("p", text: "How you doing?") + end + + it "renders a compoent with fixed positional and keyword args" do + get "/rails/view_components/mixed_args_component/fixed_args" + + expect(response.body).to have_selector("h1", text: "Hello World!") + expect(response.body).to have_selector("p", text: "How you doing?") + end + + it "renders the kitchen sink" do + get "/rails/view_components/kitchen_sink_component/jane_doe" + body = Nokogiri::HTML(response.body).css("body div").to_html + + expected_html = + <<~HTML.strip +
+

My name is Jane Doe

+

My Birthday is 1950-03-26

+

My favorite color is red

+

I like people

+

I have 2 pets

+

I like to watch football and baseball

+

My favorite food is Ice Cream

+

I'm feeling happy

+

Other things about me {"hair":"Brown","eyes":"Blue"}

+
+ HTML + + expect(body).to eq(expected_html) + end + + it "renders the kitchen sink with params" do + get "/rails/view_components/kitchen_sink_component/jane_doe", params: { + name: "John Doe", + birthday: Date.new(1963, 7, 13).iso8601, + favorite_color: "green", + like_people: false, + number_pets: 0, + sports: %i[ice_hockey basketball baseball], + favorite_food: :hot_dog, + mood: :sad, + other_things: { hair: "Blonde", eyes: "Green", weight: 175 } + } + body_div = Nokogiri::HTML(response.body).css("body div").to_html + + expected_html = + <<~HTML.strip +
+

My name is John Doe

+

My Birthday is 1963-07-13

+

My favorite color is green

+

I do not like people

+

I have no pets

+

I like to watch ice_hockey, basketball, and baseball

+

My favorite food is hot_dog

+

I'm feeling sad

+

Other things about me {"hair":"Blonde","eyes":"Green","weight":"175"}

+
+ HTML + + expect(body_div).to eq(expected_html) + end + + it "renders the compoent with supplied parameters" do + get "/rails/view_components/demo/button_component/short_button", params: { button_text: "My Button" } + + expect(response.body).to have_button(text: "My Button") + end + + it "renders a compoent with custom controls" do + get "/rails/view_components/custom_control/custom_text", params: { greeting: "Hello", name: "Nemo" } + + expect(response.body).to have_button(text: "Hello Nemo") + end + + it "renders a compoent with custom controls for rest args" do + get "/rails/view_components/custom_control/custom_rest_args", + params: { + verb_one: "Heavy", + noun_one: "Rock", + verb_two: "Light", + noun_two: "Feather", + } + + expect(response.body).to have_selector("p", text: "Heavy Rock") + expect(response.body).to have_selector("p", text: "Light Feather") + end + + xit "renders a slotable_v2 component with default values" do + get "/rails/view_components/slotable_v2/default", + params: {} + + expect(response.body).to have_selector(".card.mt-4") + + expect(response.body).to have_selector(".title", text: "This is my title!") + + expect(response.body).to have_selector(".subtitle", text: "This is my subtitle!") + + expect(response.body).to have_selector(".tab", text: "Tab A") + expect(response.body).to have_selector(".tab", text: "Tab B") + + expect(response.body).to have_selector(".item", count: 3) + expect(response.body).to have_selector(".item.highlighted", count: 1) + expect(response.body).to have_selector(".item.normal", count: 2) + + expect(response.body).to have_selector(".footer.text-blue") + end + + xit "renders a slotable_v2 component with params values" do + get "/rails/view_components/slotable_v2/default", + params: { + classes: "mb-6", + subtitle__content: "Subtitle Override!", + tab2__content: "Tab 2", + item2__highlighted: "false", + footer__classes: "text-green" + } + + expect(response.body).to have_selector(".card.mb-6") + + expect(response.body).to have_selector(".title", text: "This is my title!") + + expect(response.body).to have_selector(".subtitle", text: "Subtitle Override!") + + expect(response.body).to have_selector(".tab", text: "Tab A") + expect(response.body).to have_selector(".tab", text: "Tab 2") + + expect(response.body).to have_selector(".item", count: 3) + expect(response.body).to have_selector(".item.highlighted", count: 0) + expect(response.body).to have_selector(".item.normal", count: 3) + + expect(response.body).to have_selector(".footer.text-green") + end + + it "ignores query params that don't match the the compoents args" do + get "/rails/view_components/demo/button_component/short_button", params: { button_text: "My Button", junk: true } + + expect(response.body).to have_button(text: "My Button") + end + + it "raises ActionNotFound error for stories that don't exist" do + expect { get "/rails/view_components/missing_component/short_button" }.to raise_exception(AbstractController::ActionNotFound) + end + + it "raises ActionNotFound error story that doesn't exist" do + expect { get "/rails/view_components/demo/button_component/junk" }.to raise_exception(NameError) + end + + it "returns 200 for a stories index" do + get "/rails/view_components/demo/button_component" + + expect(response).to have_http_status(:ok) + end + + describe "component content" do + it "renders the component string content" do + get "/rails/view_components/content_component/with_string_content" + + expect(response.body).to have_selector("h1", text: "Hello World!") + end + + it "renders the component control content" do + get "/rails/view_components/content_component/with_control_content" + + expect(response.body).to include("

Hello World!

") + end + + it "renders the component control content overriden by params" do + get "/rails/view_components/content_component/with_control_content", params: { content: "Hi!" } + + expect(response.body).to include("

Hi!

") + end + + it "renders the component block content with helper" do + get "/rails/view_components/content_component/with_helper_content" + + expect(response.body).to have_css("h1 span", text: "Hello World!") + end + end + + describe "layout" do + it "defaults to the application layout" do + get "/rails/view_components/demo/button_component/short_button" + + expect(response.body).to have_title("Stories Dummy App") + end + + it "allows stories to set the layout" do + get "/rails/view_components/layout/default" + + expect(response.body).to have_title( "Stories Dummy App - Admin") + end + + # it "allows story to override the stories layout" do + # get "/rails/view_components/layout_stories_v2/mobile_layout" + + # expect(response.body).to have_title("Stories Dummy App - Mobile") + # end + + # it "allows story to override with no layout" do + # get "/rails/view_components/layout_stories_v2/no_layout" + + # expect(response.body).to eq("") + # end + + it "allows stories to set no layout" do + get "/rails/view_components/no_layout/default" + + expect(response.body.strip).to eq("") + end + + # it "allows story to override no layout with a layout" do + # get "/rails/view_components/no_layout_stories_v2/mobile_layout" + + # expect(response.body).to have_title("Stories Dummy App - Mobile") + # end + end +end diff --git a/spec/view_component/storybook/stories_controller_spec.rb b/spec/view_component/storybook/stories_controller_spec.rb index 4764571..0216968 100644 --- a/spec/view_component/storybook/stories_controller_spec.rb +++ b/spec/view_component/storybook/stories_controller_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe ViewComponent::Storybook::StoriesController, type: :request do +RSpec.xdescribe ViewComponent::Storybook::StoriesController, type: :request do it "returns ok" do get "/rails/stories/content_component/with_string_content" diff --git a/spec/view_component/storybook/stories_spec.rb b/spec/view_component/storybook/stories_spec.rb index 86570a6..124f965 100644 --- a/spec/view_component/storybook/stories_spec.rb +++ b/spec/view_component/storybook/stories_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe ViewComponent::Storybook::Stories do +RSpec.xdescribe ViewComponent::Storybook::Stories do describe ".valid?" do it "duplicate stories are invalid" do expect(Invalid::DuplicateStoryStories.valid?).to be(false) diff --git a/spec/view_component/storybook/stories_v2_spec.rb b/spec/view_component/storybook/stories_v2_spec.rb new file mode 100644 index 0000000..940a4d8 --- /dev/null +++ b/spec/view_component/storybook/stories_v2_spec.rb @@ -0,0 +1,539 @@ +# frozen_string_literal: true + +RSpec.describe ViewComponent::Storybook::StoriesV2 do + describe ".to_csf_params" do + it "converts" do + expect(ContentComponentStoriesV2.to_csf_params).to eq( + title: "Content Component", + stories: [ + { + name: :with_string_content, + parameters: { + server: { id: "content_component/with_string_content" } + } + }, + { + name: :with_control_content, + parameters: { + server: { id: "content_component/with_control_content" } + }, + args: { + content: "Hello World!" + }, + argTypes: { + content: { control: { type: :text }, name: "Content" } + } + }, + { + name: :with_described_control, + parameters: { + server: { id: "content_component/with_described_control" } + }, + args: { + content: "Hello World!" + }, + argTypes: { + content: { control: { type: :text }, description: "My first computer program.", name: "Content" } + } + }, + + { + name: :with_helper_content, + parameters: { + server: { id: "content_component/with_helper_content" } + } + }, + ] + ) + end + + it "converts kwargs" do + expect(KwargsComponentStories.to_csf_params).to eq( + title: "Kwargs Component", + stories: [ + { + name: :default, + parameters: { + server: { id: "kwargs_component/default" } + }, + args: { + message: "Hello World!", + param: 1, + other_param: true, + }, + argTypes: { + message: { control: { type: :text }, name: "Message" }, + param: { control: { type: :number }, name: "Param" }, + other_param: { control: { type: :boolean }, name: "Other Param" }, + } + }, + { + name: :fixed_args, + parameters: { + server: { id: "kwargs_component/fixed_args" } + }, + args: { + message: "Hello World!" + }, + argTypes: { + message: { control: { type: :text }, name: "Message" } + } + }, + { + name: :custom_param, + parameters: { + server: { id: "kwargs_component/custom_param" } + }, + args: { + my_message: "Hello World!", + param: 1, + }, + argTypes: { + my_message: { control: { type: :text }, name: "My Message" }, + param: { control: { type: :number }, name: "Param" }, + } + } + ] + ) + end + + it "converts args" do + expect(ArgsComponentStories.to_csf_params).to eq( + title: "Args Component", + stories: [ + { + name: :default, + parameters: { + server: { id: "args_component/default" } + }, + args: { + items0: "Hello World!", + items1: "How you doing?", + }, + argTypes: { + items0: { control: { type: :text }, name: "Items0" }, + items1: { control: { type: :text }, name: "Items1" }, + } + }, + { + name: :fixed_args, + parameters: { + server: { id: "args_component/fixed_args" } + }, + args: { + items0: "Hello World!" + }, + argTypes: { + items0: { control: { type: :text }, name: "Items0" } + } + }, + { + name: :custom_param, + parameters: { + server: { id: "args_component/custom_param" } + }, + args: { + message: "Hello World!", + items1: "How you doing?", + }, + argTypes: { + message: { control: { type: :text }, name: "Message" }, + items1: { control: { type: :text }, name: "Items1" }, + } + } + ] + ) + end + + it "converts mixed args" do + expect(MixedArgsComponentStories.to_csf_params).to eq( + title: "Mixed Args Component", + stories: [ + { + name: :default, + parameters: { + server: { id: "mixed_args_component/default" } + }, + args: { + title: "Hello World!", + message: "How you doing?", + }, + argTypes: { + title: { control: { type: :text }, name: "Title" }, + message: { control: { type: :text }, name: "Message" }, + } + }, + { + name: :fixed_args, + parameters: { + server: { id: "mixed_args_component/fixed_args" } + } + } + ] + ) + end + + + it "converts kitchen sink" do + expect(KitchenSinkComponentStoriesV2.to_csf_params).to eq( + title: "Kitchen Sink Component", + stories: [ + { + name: :jane_doe, + parameters: { + server: { id: "kitchen_sink_component/jane_doe" } + }, + args: { + name: "Jane Doe", + birthday: Time.utc(1950, 3, 26).iso8601, + favorite_color: "red", + like_people: true, + number_pets: 2, + sports: %w[football baseball], + favorite_food: "Ice Cream", + mood: :happy, + other_things: { eyes: "Blue", hair: "Brown" } + + }, + argTypes: { + name: { control: { type: :text }, name: "Name" }, + birthday: { control: { type: :date }, name: "Birthday" }, + favorite_color: { control: { type: :color }, name: "Favorite Color" }, + like_people: { control: { type: :boolean }, name: "Like People" }, + number_pets: { control: { type: :number }, name: "Number Pets" }, + sports: { control: { type: :object }, name: "Sports" }, + favorite_food: { + control: { + type: :select, + }, + name: "Favorite Food", + options: ["Burgers", "Hot Dog", "Ice Cream", "Pizza"] + }, + mood: { + control: { + type: :radio, + labels: { happy: "Happy", sad: "Sad", angry: "Angry", content: "Content" }, + }, + name: "Mood", + options: [:happy, :sad, :angry, :content] + }, + other_things: { control: { type: :object }, name: "Other Things" }, + } + } + ] + ) + end + + it "converts Stories with namespaces" do + expect(Demo::ButtonComponentStoriesV2.to_csf_params).to eq( + title: "Demo/Button Component", + stories: [ + { + name: :short_button, + parameters: { + server: { id: "demo/button_component/short_button" } + }, + args: { + button_text: "OK" + }, + argTypes: { + button_text: { control: { type: :text }, name: "Button Text" } + } + }, + { + name: :medium_button, + parameters: { + server: { id: "demo/button_component/medium_button" } + }, + args: { + button_text: "Push Me!" + }, + argTypes: { + button_text: { control: { type: :text }, name: "Button Text" } + } + }, + { + name: :long_button, + parameters: { + server: { id: "demo/button_component/long_button" } + }, + args: { + button_text: "Really Really Long Button Text" + }, + argTypes: { + button_text: { control: { type: :text }, name: "Button Text" } + } + } + ] + ) + end + + context "with a custom story title defined" do + it "converts Stories" do + expect(Demo::HeadingComponentStoriesV2.to_csf_params).to eq( + title: "Heading Component", + stories: [ + { + name: :default, + parameters: { + server: { id: "demo/heading_component/default" } + }, + args: { + heading_text: "Heading" + }, + argTypes: { + heading_text: { control: { type: :text }, name: "Heading Text" } + } + } + ] + ) + end + end + + context "with a custom story title generator defined" do + let(:custom_story_title) { "CustomStoryTitle" } + let(:component_class) do + Class.new(described_class) do + class << self + def name + "Demo::MoreButtonComponentStoriesV2" + end + end + end + end + + around do |example| + original_generator = ViewComponent::Storybook.stories_title_generator + ViewComponent::Storybook.stories_title_generator = ->(_stories) { custom_story_title } + example.run + ViewComponent::Storybook.stories_title_generator = original_generator + end + + before do + # stories_title_generator is triggered when a class is declared. + # To test this behavior we have to create a new class dynamically onew we've + # configured the stories_title_generator in the around block above + + stub_const("Demo::MoreButtonComponentStoriesV2", component_class) + end + + it "converts Stories" do + expect(Demo::MoreButtonComponentStoriesV2.to_csf_params).to eq( + title: custom_story_title, + stories: [] + ) + end + + it "allows compoents to override the title" do + expect(Demo::HeadingComponentStoriesV2.to_csf_params[:title]).to eq("Heading Component") + end + end + + xit "converts Stories with parameters" do + expect(ParametersStoriesV2.to_csf_params).to eq( + title: "Parameters", + parameters: { size: :small }, + stories: [ + { + name: :stories_parameters, + parameters: { + server: { id: "parameters/stories_parameters" } + }, + args: { + button_text: "OK" + }, + argTypes: { + button_text: { control: { type: :text }, name: "Button Text" } + } + }, + { + name: :stories_parameter_override, + parameters: { + server: { id: "parameters/stories_parameter_override" }, + size: :large, + color: :red, + }, + args: { + button_text: "OK" + }, + argTypes: { + button_text: { control: { type: :text }, name: "Button Text" } + } + }, + { + name: :additional_parameters, + parameters: { + server: { id: "parameters/additional_parameters" }, + color: :red, + }, + args: { + button_text: "OK" + }, + argTypes: { + button_text: { control: { type: :text }, name: "Button Text" } + } + } + ] + ) + end + + it "converts Stories with combined controls" do + expect(CombinedControlStories.to_csf_params).to eq( + title: "Custom Control", + stories: [ + { + name: :custom_text, + parameters: { + server: { id: "custom_control/custom_text" } + }, + args: { + greeting: "Hi", + name: "Sarah" + }, + argTypes: { + greeting: { control: { type: :text }, name: "Greeting" }, + name: { control: { type: :text }, name: "Name" } + } + }, + { + name: :custom_rest_args, + parameters: { + server: { id: "custom_control/custom_rest_args" } + }, + args: { + verb_one: "Big", + noun_one: "Car", + verb_two: "Small", + noun_two: "Boat", + }, + argTypes: { + verb_one: { control: { type: :text }, name: "Verb One" }, + noun_one: { control: { type: :text }, name: "Noun One" }, + verb_two: { control: { type: :text }, name: "Verb Two" }, + noun_two: { control: { type: :text }, name: "Noun Two" } + } + }, + { + name: :described_control, + args: { + button_text: "DO NOT PUSH!" + }, + argTypes: { + button_text: { control: { type: :text }, description: "Make this irresistible.", name: "Button Text" } + }, + parameters: { + server: { id: "custom_control/described_control" } + } + } + ] + ) + end + + it "converts Stories with slots" do + expect(SlotableV2Stories.to_csf_params).to eq( + title: "Slotable V2", + stories: [ + { + name: :default, + parameters: { + server: { id: "slotable_v2/default" } + }, + args: { + classes: "mt-4", + subtitle__content: "This is my subtitle!", + tab2__content: "Tab B", + item2__highlighted: true, + item3__content: "Item C", + footer__classes: "text-blue" + }, + argTypes: { + classes: { control: { type: :text }, name: "Classes" }, + subtitle__content: { control: { type: :text }, name: "Subtitle Content" }, + tab2__content: { control: { type: :text }, name: "Tab2 Content" }, + item2__highlighted: { control: { type: :boolean }, name: "Item2 Highlighted" }, + item3__content: { control: { type: :text }, name: "Item3 Content" }, + footer__classes: { control: { type: :text }, name: "Footer Classes" } + } + } + ] + ) + end + end + + describe ".write_csf_json" do + subject { ContentComponentStoriesV2.write_csf_json } + + after do + File.delete(subject) + end + + it "writes stories to json files" do + json_file = File.read(subject) + expect(json_file).to eq( + <<~JSON.strip + { + "title": "Content Component", + "stories": [ + { + "name": "with_string_content", + "parameters": { + "server": { + "id": "content_component/with_string_content" + } + } + }, + { + "name": "with_control_content", + "parameters": { + "server": { + "id": "content_component/with_control_content" + } + }, + "args": { + "content": "Hello World!" + }, + "argTypes": { + "content": { + "control": { + "type": "text" + }, + "name": "Content" + } + } + }, + { + "name": "with_described_control", + "parameters": { + "server": { + "id": "content_component/with_described_control" + } + }, + "args": { + "content": "Hello World!" + }, + "argTypes": { + "content": { + "control": { + "type": "text" + }, + "name": "Content", + "description": "My first computer program." + } + } + }, + { + "name": "with_helper_content", + "parameters": { + "server": { + "id": "content_component/with_helper_content" + } + } + } + ] + } + JSON + ) + end + end + +end diff --git a/view_component_storybook.gemspec b/view_component_storybook.gemspec index c0525aa..3fa462a 100644 --- a/view_component_storybook.gemspec +++ b/view_component_storybook.gemspec @@ -39,6 +39,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 2.6.0" spec.add_dependency "view_component", ">= 2.54" + spec.add_dependency "yard", "~> 0.9.25" spec.add_development_dependency "bundler", "~> 2.2" spec.add_development_dependency "capybara", "~> 3" From 5946b57441264a34b8bab44311068f639ec9cb3c Mon Sep 17 00:00:00 2001 From: Jon Palmer <328224+jonspalmer@users.noreply.github.com> Date: Sat, 17 Dec 2022 15:22:49 -0500 Subject: [PATCH 02/35] Add slots examples --- lib/view_component/storybook/stories_v2.rb | 29 ++++++++++++ ...nent.html.erb => slots_component.html.erb} | 6 +-- ...ots_v2_component.rb => slots_component.rb} | 2 +- .../my_highlight_component.html.erb | 0 .../stories/combined_control_stories.rb | 10 ++--- .../components/stories/slotable_v2_stories.rb | 33 -------------- .../test/components/stories/slots_stories.rb | 28 ++++++++++++ .../storybook/previews_controller_spec.rb | 44 +++++++++---------- .../storybook/slots/slot_config_spec.rb | 26 +++++------ .../storybook/stories_v2_spec.rb | 40 +++++++++-------- .../storybook/story_config_spec.rb | 8 ++-- 11 files changed, 126 insertions(+), 100 deletions(-) rename spec/dummy/app/components/{slots_v2_component.html.erb => slots_component.html.erb} (89%) rename spec/dummy/app/components/{slots_v2_component.rb => slots_component.rb} (95%) rename spec/dummy/app/components/{slots_v2_component => slots_component}/my_highlight_component.html.erb (100%) delete mode 100644 spec/dummy/test/components/stories/slotable_v2_stories.rb create mode 100644 spec/dummy/test/components/stories/slots_stories.rb diff --git a/lib/view_component/storybook/stories_v2.rb b/lib/view_component/storybook/stories_v2.rb index 7001de6..d0d12b4 100644 --- a/lib/view_component/storybook/stories_v2.rb +++ b/lib/view_component/storybook/stories_v2.rb @@ -48,6 +48,35 @@ def story_configs end end + # find the story by name + def find_story_config(name) + story_configs.find { |config| config.name == name.to_sym } + end + + # Returns the arguments for rendering of the component in its layout + def render_args(story_name, params: {}) + story_params_names = instance_method(story_name).parameters.map(&:last) + provided_params = params.slice(*story_params_names).to_h.symbolize_keys + + + story_config = find_story_config(story_name) + + control_parsed_params = provided_params.map do |param, value| + control = story_config.controls.find { |control| control.param == param } + if control + [param, control.value_from_params(params)] + else + [param, value] + end + end.to_h + + result = control_parsed_params.empty? ? new.public_send(story_name) : new.public_send(story_name, **control_parsed_params) + result ||= {} + result[:template] = preview_example_template_path(story_name) if result[:template].nil? + @layout = nil unless defined?(@layout) + result.merge(layout: @layout) + end + private def inherited(other) diff --git a/spec/dummy/app/components/slots_v2_component.html.erb b/spec/dummy/app/components/slots_component.html.erb similarity index 89% rename from spec/dummy/app/components/slots_v2_component.html.erb rename to spec/dummy/app/components/slots_component.html.erb index 7b336c1..6e2b9fa 100644 --- a/spec/dummy/app/components/slots_v2_component.html.erb +++ b/spec/dummy/app/components/slots_component.html.erb @@ -6,7 +6,7 @@ <%= subtitle %>