diff --git a/Gemfile b/Gemfile index 78b3eca..3b3bbca 100644 --- a/Gemfile +++ b/Gemfile @@ -8,4 +8,9 @@ group :development do gem "sprockets-rails" gem "foreman" gem "github_changelog_generator", "~> 1.16" + + # gem 'better_errors' + # gem 'binding_of_caller' + # gem 'listen' + gem 'web-console' end diff --git a/Gemfile.lock b/Gemfile.lock index 64bdcbb..f11c560 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - phlex_storybook (0.1.9) + phlex_storybook (0.2.0) importmap-rails phlex-icons (~> 0.11.0) phlex-rails (~> 1.2) @@ -109,6 +109,7 @@ GEM traces base64 (0.2.0) bigdecimal (3.1.8) + bindex (0.8.1) builder (3.3.0) concurrent-ruby (1.3.4) connection_pool (2.4.1) @@ -300,6 +301,11 @@ GEM concurrent-ruby (~> 1.0) uri (0.13.1) useragent (0.16.10) + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) webrick (1.8.1) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) @@ -320,6 +326,7 @@ DEPENDENCIES phlex_storybook! puma sprockets-rails + web-console BUNDLED WITH 2.5.5 diff --git a/app/assets/stylesheets/phlex_storybook/application.tailwind.css b/app/assets/stylesheets/phlex_storybook/application.tailwind.css index 9df0912..f375780 100644 --- a/app/assets/stylesheets/phlex_storybook/application.tailwind.css +++ b/app/assets/stylesheets/phlex_storybook/application.tailwind.css @@ -50,7 +50,7 @@ } } -li.story-preview-active, li.story-code-active { +li.story-button-active { @apply rounded border-slate-200 bg-slate-900; } diff --git a/app/controllers/phlex_storybook/stories_controller.rb b/app/controllers/phlex_storybook/stories_controller.rb index 4ee304c..eda7291 100644 --- a/app/controllers/phlex_storybook/stories_controller.rb +++ b/app/controllers/phlex_storybook/stories_controller.rb @@ -74,7 +74,7 @@ def story_components end def story_component - story_components.detect { |e| e.component_name == params[:id] } + story_components.detect { |_k, e| e.name == params[:id] }&.last end end end diff --git a/app/javascript/phlex_storybook/controllers/story_display_controller.js b/app/javascript/phlex_storybook/controllers/story_display_controller.js index 6b5907f..17ca904 100644 --- a/app/javascript/phlex_storybook/controllers/story_display_controller.js +++ b/app/javascript/phlex_storybook/controllers/story_display_controller.js @@ -1,19 +1,33 @@ import { Controller } from "@hotwired/stimulus"; export default class extends Controller { - static targets = ["previewBtn", "codeBtn", "preview", "code"]; + static targets = ["previewBtn", "codeBtn", "sourceBtn", "preview", "code", "source"]; showCode() { - this.previewTarget.classList.add('hidden'); - this.codeTarget.classList.remove('hidden'); - this.previewBtnTarget.classList.remove('story-preview-active'); - this.codeBtnTarget.classList.add('story-code-active'); + this.activate({displayTarget: this.codeTarget, buttonTarget: this.codeBtnTarget}); + this.deactivate({displayTarget: this.previewTarget, buttonTarget: this.previewBtnTarget}); + this.deactivate({displayTarget: this.sourceTarget, buttonTarget: this.sourceBtnTarget}); + } + + showComponentSource() { + this.activate({displayTarget: this.sourceTarget, buttonTarget: this.sourceBtnTarget}); + this.deactivate({displayTarget: this.codeTarget, buttonTarget: this.codeBtnTarget}); + this.deactivate({displayTarget: this.previewTarget, buttonTarget: this.previewBtnTarget}); } showPreview() { - this.previewTarget.classList.remove('hidden'); - this.codeTarget.classList.add('hidden'); - this.previewBtnTarget.classList.add('story-preview-active'); - this.codeBtnTarget.classList.remove('story-code-active'); + this.activate({displayTarget: this.previewTarget, buttonTarget: this.previewBtnTarget}); + this.deactivate({displayTarget: this.codeTarget, buttonTarget: this.codeBtnTarget}); + this.deactivate({displayTarget: this.sourceTarget, buttonTarget: this.sourceBtnTarget}); + } + + activate({displayTarget, buttonTarget}) { + displayTarget.classList.remove('hidden'); + buttonTarget.classList.add('story-button-active'); + } + + deactivate({displayTarget, buttonTarget}) { + displayTarget.classList.add('hidden'); + buttonTarget.classList.remove('story-button-active'); } } diff --git a/lib/phlex_storybook.rb b/lib/phlex_storybook.rb index d26a4dd..385759a 100644 --- a/lib/phlex_storybook.rb +++ b/lib/phlex_storybook.rb @@ -12,6 +12,19 @@ # loader.setup module PhlexStorybook + autoload :DSL, "phlex_storybook/dsl" + autoload :ApplicationComponent, "phlex_storybook/application_component" + autoload :ApplicationView, "phlex_storybook/application_view" + autoload :ComponentStory, "phlex_storybook/component_story" + + module Props + autoload :Base, "phlex_storybook/props/base" + autoload :String, "phlex_storybook/props/string" + autoload :Select, "phlex_storybook/props/select" + autoload :Text, "phlex_storybook/props/text" + autoload :Boolean, "phlex_storybook/props/boolean" + end + class << self attr_writer :configuration diff --git a/lib/phlex_storybook/application_view.rb b/lib/phlex_storybook/application_view.rb index 29612ea..fb694b6 100644 --- a/lib/phlex_storybook/application_view.rb +++ b/lib/phlex_storybook/application_view.rb @@ -11,6 +11,6 @@ class ApplicationView < ApplicationComponent include Phlex::Rails::Helpers::PathToAsset include Phlex::Rails::Helpers::Request include Phlex::Rails::Helpers::Tag - include ApplicationHelper + # include ApplicationHelper end end diff --git a/lib/phlex_storybook/assets/phlex_storybook_application.css b/lib/phlex_storybook/assets/phlex_storybook_application.css index 3ce3e5b..a3cbdf7 100644 --- a/lib/phlex_storybook/assets/phlex_storybook_application.css +++ b/lib/phlex_storybook/assets/phlex_storybook_application.css @@ -1178,7 +1178,7 @@ fieldset{ } } -li.story-preview-active, li.story-code-active{ +li.story-button-active{ border-radius: 0.25rem; --tw-border-opacity: 1; border-color: rgb(226 232 240 / var(--tw-border-opacity)); diff --git a/lib/phlex_storybook/components/stories/component_display.rb b/lib/phlex_storybook/components/stories/component_display.rb index a70ac8d..31f262c 100644 --- a/lib/phlex_storybook/components/stories/component_display.rb +++ b/lib/phlex_storybook/components/stories/component_display.rb @@ -22,7 +22,7 @@ def view_template turbo_frame_tag("component_display") do div(class: "flex flex-col h-screen max-h-screen", data: { controller: "story-display copy" }) do - render_header @story_component.component_name + render_header @story_component.name div(class: "px-2 flex-1 overflow-y-scroll overflow-x-hidden") do props = @props.blank? ? @story_component.story_for(@story_id) : @props if @story_id && props.nil? @@ -30,24 +30,15 @@ def view_template next end - div(class: "mb-4") { @story_component.component_description } + div(class: "mb-4") { @story_component.description } - if @story_id - div(class: "container w-full h-fit min-w-0 mr-0") do - render_story_header - render ComponentRendering.new( - story_component: @story_component, - story_id: @story_id, - **props.except(:title).map.with_object({}) { |(k, v), h| h[k] = v } - ) - end - elsif @story_component.component_stories.present? - div { "Select a story from the left to see its usage..." } - else - div(class: "container w-full h-fit min-w-0 mr-0") do - render_story_header - render ComponentRendering.new(story_component: @story_component, **@props) - end + div(class: "container w-full h-fit min-w-0 mr-0") do + render_story_header + render ComponentRendering.new( + story_component: @story_component, + story_id: @story_id, + **@story_component.default_story.merge(props || {}), + ) end end end @@ -69,7 +60,7 @@ def render_story_header div(class: "flex justify-between") do ul(class: "text-sm text-white inline-flex bg-slate-700 rounded border-slate-200") do li( - class: "relative px-3 py-2 flex-grow-1 story-preview-active", + class: "relative px-3 py-2 flex-grow-1 story-button-active", data: { action: "click->story-display#showPreview click->copy#disable:prevent", story_display_target: "previewBtn", @@ -87,6 +78,16 @@ def render_story_header ) do button(class: "font-semibold") { "Ruby Code" } end + + li( + class: "relative px-3 py-2 flex-grow-1", + data: { + action: 'click->story-display#showComponentSource click->copy#disable:prevent', + story_display_target: 'sourceBtn', + }, + ) do + button(class: "font-semibold") { "Component Source" } + end end button( disabled: true, diff --git a/lib/phlex_storybook/components/stories/component_properties.rb b/lib/phlex_storybook/components/stories/component_properties.rb index e6a975e..07107fd 100644 --- a/lib/phlex_storybook/components/stories/component_properties.rb +++ b/lib/phlex_storybook/components/stories/component_properties.rb @@ -14,7 +14,7 @@ def initialize(story_component:, story_id: nil) end def view_template - if @story_component&.component_props.blank? + if @story_component&.props.blank? turbo_frame_tag("component_properties") do h2(class: "bg-slate-900 p-2") { "Properties" } div(class: "px-2") { "No properties" } @@ -27,7 +27,7 @@ def view_template div(class: "px-2") do form(action: helpers.story_path(@story_component, story_id: @story_id), method: 'PUT') do ul do - @story_component.component_props.each do |prop| + @story_component.props.each do |prop| li do label(class: 'grid grid-cols-1 w-full') do div { prop.label || prop.key.to_s.capitalize } diff --git a/lib/phlex_storybook/components/stories/component_rendering.rb b/lib/phlex_storybook/components/stories/component_rendering.rb index 17f5e98..4bdd639 100644 --- a/lib/phlex_storybook/components/stories/component_rendering.rb +++ b/lib/phlex_storybook/components/stories/component_rendering.rb @@ -16,24 +16,30 @@ def view_template render @story_component.component.new(**@props) end - div( - class: "hidden mt-4 w-full", - data: { story_display_target: "code" }, - ) do - div(id: "component-code", class: "p-2 overflow-auto scroll") do - source = @props.blank? ? "render #{@story_component.component.name}.new" : <<~RUBY.strip - render #{@story_component.component.name}.new( - #{@props.map { |k, v| "#{k}: #{v.inspect}," }.join("\n ")} - ) - RUBY - pre(class: "hidden", data: { copy_target: "source" }) { source } - formatter = Rouge::Formatters::HTMLLineTable.new(Rouge::Formatters::HTML.new) - lexer = Rouge::Lexers::Ruby.new - unsafe_raw formatter.format(lexer.lex(source)) - end - style do - unsafe_raw Rouge::Themes::Molokai.render(scope: '#component-code') - end + source = @props.blank? ? "render #{@story_component.component.name}.new" : <<~RUBY.strip + render #{@story_component.component.name}.new( + #{@props.map { |k, v| "#{k}: #{v.inspect}," }.join("\n ")} + ) + RUBY + + render_ruby_code "code", source + render_ruby_code "source", @story_component.source + end + end + + def render_ruby_code(id, source) + div( + class: "hidden mt-4 w-full", + data: { story_display_target: id }, + ) do + div(id: id, class: "p-2 overflow-auto scroll") do + pre(class: "hidden", data: { copy_target: "source" }) { source } + formatter = Rouge::Formatters::HTMLLineTable.new(Rouge::Formatters::HTML.new) + lexer = Rouge::Lexers::Ruby.new + unsafe_raw formatter.format(lexer.lex(source)) + end + style do + unsafe_raw Rouge::Themes::Molokai.render(scope: "##{id}") end end end diff --git a/lib/phlex_storybook/components/stories/component_selector.rb b/lib/phlex_storybook/components/stories/component_selector.rb index b85a920..54b7c20 100644 --- a/lib/phlex_storybook/components/stories/component_selector.rb +++ b/lib/phlex_storybook/components/stories/component_selector.rb @@ -5,7 +5,7 @@ module Components module Stories class ComponentSelector < ApplicationView def initialize(story_components:, selected: nil, selected_story: nil) - @story_components_by_category = story_components.group_by(&:component_category).sort + @story_components_by_category = story_components.values.group_by(&:category).sort @selected = selected @selected_story = selected_story end @@ -21,10 +21,10 @@ def view_template li do component_link(story_component) - if story_component.component_stories.present? + if story_component.stories.present? ul do - story_component.component_stories&.each do |props| - li(class: "pl-4 text-sm") { story_link(story_component, props) } + story_component.stories&.each do |title, props| + li(class: "pl-4 text-sm") { story_link(story_component, title, props) } end end end @@ -46,13 +46,13 @@ def component_link(story_component) active = story_component == @selected a( data: { turbo_stream: true }, - href: helpers.story_path(story_component.component_name), + href: helpers.story_path(story_component.name), class: "#{active ? active_selection : ''}", ) do span(class: "pr-1") do render Phlex::Icons::Lucide::Component.new(classes: "#{icon_color(active)} size-4 inline") end - span { story_component.component_name } + span { story_component.name } end end @@ -60,8 +60,7 @@ def icon_color(state) state ? 'stroke-sky-500' : 'stroke-slate-300' end - def story_link(story_component, props) - title = props.delete :title + def story_link(story_component, title, props) id = story_component.id_for(title) active = id == @selected_story icon = active ? Phlex::Icons::Lucide::NotebookText : Phlex::Icons::Lucide::Notebook diff --git a/lib/phlex_storybook/configuration.rb b/lib/phlex_storybook/configuration.rb index 9ebe58d..e97c11a 100644 --- a/lib/phlex_storybook/configuration.rb +++ b/lib/phlex_storybook/configuration.rb @@ -1,25 +1,15 @@ +require_relative "story_component" + module PhlexStorybook class Configuration - attr_accessor :component_directories + attr_reader :components def initialize - @component_directories = %w[app/components] - end - - def components - component_directories.flat_map do |dir| - Dir.glob("#{dir}/**/*.rb").select { |f| File.file?(f) }.map do |file| - StoryComponent.new File.basename(file, ".rb").camelize.constantize, file - end - end - end - - def component_directories - @component_directories.map { |dir| Rails.root.join dir } + @components = {} end - def component_directories=(directories) - @component_directories = directories + def register(component, location) + @components[component] = StoryComponent.new(component, location: location) end end end diff --git a/lib/phlex_storybook/dsl.rb b/lib/phlex_storybook/dsl.rb new file mode 100644 index 0000000..d8d7f3a --- /dev/null +++ b/lib/phlex_storybook/dsl.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module PhlexStorybook + module DSL + extend ActiveSupport::Concern + + class_methods do + def __find_registered__ + PhlexStorybook.configuration.components[self] + end + + def component_category(v) + __find_registered__.category = v + end + + def component_description(v) + __find_registered__.description = v + end + + def component_name(v) + __find_registered__.name = v + end + + def component_props(v) + __find_registered__.props = v + end + + def component_stories(v) + __find_registered__.stories = v + end + + def register_component(&block) + PhlexStorybook.configuration.register(self, block.source_location).tap { |sc| sc.name = name } + block.call + end + end + end +end diff --git a/lib/phlex_storybook/engine.rb b/lib/phlex_storybook/engine.rb index 6f250ab..839ee3b 100644 --- a/lib/phlex_storybook/engine.rb +++ b/lib/phlex_storybook/engine.rb @@ -1,3 +1,5 @@ +require_relative "dsl" + require "importmap-rails" require "turbo-rails" require "stimulus-rails" diff --git a/lib/phlex_storybook/story_component.rb b/lib/phlex_storybook/story_component.rb index e667766..4117ecd 100644 --- a/lib/phlex_storybook/story_component.rb +++ b/lib/phlex_storybook/story_component.rb @@ -1,56 +1,43 @@ module PhlexStorybook class StoryComponent - attr_reader :component, :location + attr_reader :component + attr_accessor :category, :description, :location, :name, :props, :stories + alias to_param name - def initialize(component, location) - @component = component - @location = location - end - - def component_category - component.respond_to?(:component_category) ? component.component_category : "Uncategorized" - end - - def component_description - component.respond_to?(:component_description) ? component.component_description : "No description provided" - end - - def component_name - component.respond_to?(:component_name) ? component.component_name : component.name - end - alias to_param component_name - - def component_props - component.respond_to?(:component_props) ? component.component_props : [] - end - - def component_stories - component.respond_to?(:component_stories) ? component.component_stories : [] + def initialize(component, location: nil) + @component = component + @location = location || nil #component.source_location + @name = component.name + @category = "Uncategorized" + @description = "No description provided" + @props = [] + @stories = {} end def default_story - component_props + props .select { |prop| prop.required } .map { |prop| [prop.key, prop.default || prop.class.default] } .to_h end - def default_string = "" - def default_string_list = [] - def id_for(title) Digest::MD5.hexdigest(title) end + def source + File.read component.instance_method(:view_template).source_location.first + end + def story_for(id) - component_stories.detect { |props| id_for(props[:title]) == id } + stories.detect { |key, props| id_for(key) == id }&.last end - def transform_props(props) - return props if component_props.blank? + def transform_props(user_data) + return user_data if user_data.blank? - props.map.with_object({}) do |(k, v), h| - h[k] = component_props.detect { |prop| prop.key == k }&.transform(v) + user_data.map.with_object({}) do |(k, v), h| + h[k] = props.detect { |prop| prop.key == k }&.transform(v) end.compact end end diff --git a/lib/phlex_storybook/version.rb b/lib/phlex_storybook/version.rb index 92a10b4..76e6755 100644 --- a/lib/phlex_storybook/version.rb +++ b/lib/phlex_storybook/version.rb @@ -1,3 +1,3 @@ module PhlexStorybook - VERSION = "0.1.9" + VERSION = "0.2.0" end diff --git a/test/dummy/app/components/another_dummy_component.rb b/test/dummy/app/components/another_dummy_component.rb index a19fb8d..4f4143c 100644 --- a/test/dummy/app/components/another_dummy_component.rb +++ b/test/dummy/app/components/another_dummy_component.rb @@ -1,12 +1,20 @@ # frozen_string_literal: true class AnotherDummyComponent < Phlex::HTML - def self.component_category - "Category 2" + include PhlexStorybook::DSL + + def self.long_string + 100.times.map { |i| "list item #{i + 1}" }.join("\n") + end + + def self.short_string + 3.times.map { |i| "Candidate #{i + 1}" }.join("\n") end - def self.component_description - <<~TEXT + register_component do + component_category "Category 2" + + component_description <<~TEXT Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi hendrerit libero euismod nisl venenatis sollicitudin. Curabitur in leo vel justo vulputate ornare at id dolor. @@ -27,31 +35,17 @@ def self.component_description Ut non tortor nec ipsum euismod dignissim in suscipit leo. Sed imperdiet risus sit amet mi rutrum luctus. TEXT - end - def self.component_props - [ + component_props [ PhlexStorybook::Props::String.new(key: :header, placeholder: "The header", required: true), PhlexStorybook::Props::Text.new(key: :text, label: "The list"), PhlexStorybook::Props::Boolean.new(key: :truthy, label: "Truthy"), PhlexStorybook::Props::Select.new(key: :selectable, label: "Select", include_blank: true, options: %w[Option1 Option2 Option3]), PhlexStorybook::Props::Select.new(key: :multi_selectable, label: "Select Several", multiple: true, options: %w[Option1 Option2 Option3]), ] - end - def self.component_stories - [ - { title: "Short List", header: "Candidates", text: short_string, truthy: true }, - { title: "Still Deciding", header: "Things", text: long_string, truthy: false }, - ] - end - - def self.long_string - 100.times.map { |i| "list item #{i + 1}" }.join("\n") - end - - def self.short_string - 3.times.map { |i| "Candidate #{i + 1}" }.join("\n") + component_stories "Short List" => { header: "Candidates", text: short_string, truthy: true }, + "Still Deciding" => { header: "Things", text: long_string, truthy: false } end def initialize(header:, text: "", truthy: false, selectable: [], multi_selectable: []) diff --git a/test/dummy/app/components/dummy_component.rb b/test/dummy/app/components/dummy_component.rb index 62b0abd..2954dd0 100644 --- a/test/dummy/app/components/dummy_component.rb +++ b/test/dummy/app/components/dummy_component.rb @@ -1,16 +1,12 @@ # frozen_string_literal: true class DummyComponent < Phlex::HTML - def self.component_category - "Category 1" - end - - def self.component_description - "This is a dummy component" - end + include PhlexStorybook::DSL - def self.component_name - "Dummy Component" + register_component do + component_category "Category 1" + component_description "This is a dummy component" + component_name "Dummy Component" end def view_template diff --git a/test/dummy/app/components/rendered_results_preview_component.rb b/test/dummy/app/components/rendered_results_preview_component.rb index 3364371..546a9f2 100644 --- a/test/dummy/app/components/rendered_results_preview_component.rb +++ b/test/dummy/app/components/rendered_results_preview_component.rb @@ -1,17 +1,5 @@ class RenderedResultsPreviewComponent < Phlex::HTML - - def self.component_props - [ - PhlexStorybook::Props::Text.new(key: :html, placeholder: "HTML", required: true), - ] - end - - def self.component_stories - [ - { title: "Short Doc", html: short_html }, - { title: "Long Doc", html: long_html }, - ] - end + include PhlexStorybook::DSL def self.short_html <<~HTML @@ -57,6 +45,13 @@ def self.long_html HTML end + register_component do + component_props [ + PhlexStorybook::Props::Text.new(key: :html, placeholder: "HTML", required: true), + ] + component_stories "Short Doc" => { html: short_html }, "Long Doc" => { html: long_html } + end + def initialize(html:) @html = html @formatter = Rouge::Formatters::HTML.new(css_class: 'highlight') diff --git a/test/dummy/app/components/storyless_component.rb b/test/dummy/app/components/storyless_component.rb index d22675e..26d4aa9 100644 --- a/test/dummy/app/components/storyless_component.rb +++ b/test/dummy/app/components/storyless_component.rb @@ -1,20 +1,13 @@ # frozen_string_literal: true class StorylessComponent < Phlex::HTML - def self.component_category - "Category 1" - end - - def self.component_description - "This is a storyless component" - end - - def self.component_name - "Component without stories" - end + include PhlexStorybook::DSL - def self.component_props - [ + register_component do + component_category "Category 1" + component_description "This is a storyless component" + component_name "Component without stories" + component_props [ PhlexStorybook::Props::String.new(key: :header, default: "Default Header", required: true), PhlexStorybook::Props::Text.new(key: :text, label: "The list"), ] diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb index 1dd7940..1e09202 100644 --- a/test/dummy/config/application.rb +++ b/test/dummy/config/application.rb @@ -21,9 +21,11 @@ module Dummy class Application < Rails::Application config.autoload_paths << "#{root}/app/views" config.autoload_paths << "#{root}/app/views/layouts" - config.autoload_paths << "#{root}/app/views/components" + config.autoload_paths << "#{root}/app/components" config.load_defaults Rails::VERSION::STRING.to_f + Dir["#{root}/app/components/**/*.rb"].each { |file| require file } + # For compatibility with applications that use this config config.action_controller.include_all_helpers = false