diff --git a/app/controllers/phlex_storybook/components_controller.rb b/app/controllers/phlex_storybook/components_controller.rb new file mode 100644 index 0000000..698ad8e --- /dev/null +++ b/app/controllers/phlex_storybook/components_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module PhlexStorybook + class ComponentsController < ApplicationController + def update + respond_to do |format| + format.turbo_stream do + case story_component.update_source(params[:ruby]) + in {success: true} + story_id = params[:story_id] + redirect_to story_path(story_component, story_id: story_id) + in {success: false, error:} + render turbo_stream: [ + turbo_stream.replace( + "saving", + "error: #{error.message}", + ), + ] + else + # huh?!?! + end + end + end + end + + private + + def story_component + PhlexStorybook.configuration.components.detect { |_k, e| e.name == params[:id] }&.last + end + end +end diff --git a/app/javascript/phlex_storybook/controllers/code_editor_controller.js b/app/javascript/phlex_storybook/controllers/code_editor_controller.js new file mode 100644 index 0000000..66634a4 --- /dev/null +++ b/app/javascript/phlex_storybook/controllers/code_editor_controller.js @@ -0,0 +1,14 @@ +import { Controller } from "@hotwired/stimulus" +import * as monaco from "monaco-editor" + +export default class extends Controller { + static targets = ["ruby"]; + + connect() { + monaco.editor.create(this.element, {value: this.source(), language: "ruby", theme: "vs-dark"}); + } + + source() { + return this.rubyTarget.textContent; + } +} diff --git a/app/javascript/phlex_storybook/controllers/story_display_controller.js b/app/javascript/phlex_storybook/controllers/story_display_controller.js index 17ca904..5aa95be 100644 --- a/app/javascript/phlex_storybook/controllers/story_display_controller.js +++ b/app/javascript/phlex_storybook/controllers/story_display_controller.js @@ -1,24 +1,50 @@ import { Controller } from "@hotwired/stimulus"; +import * as monaco from "monaco-editor" export default class extends Controller { - static targets = ["previewBtn", "codeBtn", "sourceBtn", "preview", "code", "source"]; + static targets = [ + "code", + "codeBtn", + "editor", + "preview", + "previewBtn", + "ruby", + "source", + "sourceBtn", + ]; + + getComponentSource() { + return this.rubyTarget.textContent; + } + + saveComponentSource() { + this.rubyTarget.textContent = this.editor.getValue(); + } showCode() { this.activate({displayTarget: this.codeTarget, buttonTarget: this.codeBtnTarget}); this.deactivate({displayTarget: this.previewTarget, buttonTarget: this.previewBtnTarget}); this.deactivate({displayTarget: this.sourceTarget, buttonTarget: this.sourceBtnTarget}); + this.editor = null; } 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}); + this.editorTarget.style.height = `${this.element.clientHeight * 0.66}px`; + this.editor = monaco.editor.create(this.editorTarget, { + value: this.getComponentSource(), + language: "ruby", + theme: "vs-dark", + }); } showPreview() { this.activate({displayTarget: this.previewTarget, buttonTarget: this.previewBtnTarget}); this.deactivate({displayTarget: this.codeTarget, buttonTarget: this.codeBtnTarget}); this.deactivate({displayTarget: this.sourceTarget, buttonTarget: this.sourceBtnTarget}); + this.editor = null; } activate({displayTarget, buttonTarget}) { diff --git a/config/importmap.rb b/config/importmap.rb index 681e255..9833ff6 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -3,4 +3,6 @@ pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true pin "turbo_power", to: "https://ga.jspm.io/npm:turbo_power@0.1.6/dist/index.js" +pin 'monaco-editor', to: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.51.0/+esm' + pin_all_from PhlexStorybook::Engine.root.join("app/javascript/phlex_storybook/controllers"), under: "controllers", to: "phlex_storybook/controllers" diff --git a/config/routes.rb b/config/routes.rb index 20ae717..05d0835 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,9 +1,14 @@ PhlexStorybook::Engine.routes.draw do root to: 'stories#index' + resources :components, only: [:update] + resources :stories, only: [:index, :show, :update] do collection do get :all + + get 'codicon.ttf', to: redirect( + 'https://cdn.jsdelivr.net/npm/monaco-editor@0.51.0/esm/vs/base/browser/ui/codicons/codicon/codicon.ttf') end end end diff --git a/lib/phlex_storybook/assets/phlex_storybook_application.css b/lib/phlex_storybook/assets/phlex_storybook_application.css index 8835cbf..f1bcf5b 100644 --- a/lib/phlex_storybook/assets/phlex_storybook_application.css +++ b/lib/phlex_storybook/assets/phlex_storybook_application.css @@ -1045,6 +1045,11 @@ fieldset{ border-color: rgb(51 65 85 / var(--tw-border-opacity)); } +.bg-blue-500{ + --tw-bg-opacity: 1; + background-color: rgb(59 130 246 / var(--tw-bg-opacity)); +} + .bg-gray-100{ --tw-bg-opacity: 1; background-color: rgb(243 244 246 / var(--tw-bg-opacity)); @@ -1100,6 +1105,11 @@ fieldset{ padding-right: 0.75rem; } +.px-4{ + padding-left: 1rem; + padding-right: 1rem; +} + .py-1{ padding-top: 0.25rem; padding-bottom: 0.25rem; @@ -1128,6 +1138,10 @@ fieldset{ line-height: 1rem; } +.font-bold{ + font-weight: 700; +} + .font-semibold{ font-weight: 600; } @@ -1213,6 +1227,11 @@ li.story-button-active{ color: rgb(75 85 99 / var(--tw-text-opacity)); } +.hover\:bg-blue-700:hover{ + --tw-bg-opacity: 1; + background-color: rgb(29 78 216 / var(--tw-bg-opacity)); +} + .hover\:ring-1:hover{ --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); diff --git a/lib/phlex_storybook/components/stories/component_properties.rb b/lib/phlex_storybook/components/stories/component_properties.rb index bd97ccf..532a512 100644 --- a/lib/phlex_storybook/components/stories/component_properties.rb +++ b/lib/phlex_storybook/components/stories/component_properties.rb @@ -31,7 +31,7 @@ def view_template li do label(class: 'grid grid-cols-1 w-full') do div { prop.label } - render prop.clone_with_value(@props&.fetch(prop.position, @props&.fetch(prop.key, nil))) + render prop.clone_from(@props) end end end diff --git a/lib/phlex_storybook/components/stories/component_rendering.rb b/lib/phlex_storybook/components/stories/component_rendering.rb index 9cfee25..e8a44f9 100644 --- a/lib/phlex_storybook/components/stories/component_rendering.rb +++ b/lib/phlex_storybook/components/stories/component_rendering.rb @@ -11,17 +11,41 @@ def initialize(story_component:, story_id: nil, **props) end def view_template - initializer = PhlexStorybook::Props::ComponentInitializer.create(@story_component, **@props) + component_instance, component_source = @story_component.evaluate!(**@props) + turbo_frame_tag("story-rendering") do div(data: { story_display_target: "preview" }) do - render initializer.initialize_component(@story_component.component) + render component_instance + end + + render_ruby_code "code", component_source + + div(class: "hidden mt-4 w-full", data: { story_display_target: "source" }) do + if PhlexStorybook.configuration.editable? + action = helpers.component_path(@story_component, story_id: @story_id) + form(action: action, method: "PUT") do + render_component_source + + div(class: "hidden") { @story_component.props.each { |prop| render prop.clone_from(@props) } } + + button( + class: "mt-4 bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded", + data: { action: "click->story-display#saveComponentSource" }, + ) { "Save" } + end + else + render_component_source + end end + end + end - args = @props.blank? ? "" : "(#{initializer.parameters_as_string}\n)" - source = "render #{@story_component.component.name}.new#{args}".strip + private - render_ruby_code "code", source - render_ruby_code "source", @story_component.source + def render_component_source + div id: "source", class: "p-2 overflow-auto scroll", data: { story_display_target: "editor" } + textarea(class: "hidden", data: { story_display_target: "ruby" }, name: "ruby") do + @story_component.source end end diff --git a/lib/phlex_storybook/configuration.rb b/lib/phlex_storybook/configuration.rb index e97c11a..ef0e34a 100644 --- a/lib/phlex_storybook/configuration.rb +++ b/lib/phlex_storybook/configuration.rb @@ -8,6 +8,10 @@ def initialize @components = {} end + def editable? + Rails.env.development? + end + def register(component, location) @components[component] = StoryComponent.new(component, location: location) end diff --git a/lib/phlex_storybook/props/base.rb b/lib/phlex_storybook/props/base.rb index 691b662..87613b6 100644 --- a/lib/phlex_storybook/props/base.rb +++ b/lib/phlex_storybook/props/base.rb @@ -25,6 +25,10 @@ def add_to(initializer) end end + def clone_from(hash) + clone_with_value(hash&.fetch(position, hash&.fetch(key, nil))) + end + def clone_with_value(v) p = dup p.instance_variable_set(:@value, v) diff --git a/lib/phlex_storybook/story_component.rb b/lib/phlex_storybook/story_component.rb index 00ff4ef..2f22869 100644 --- a/lib/phlex_storybook/story_component.rb +++ b/lib/phlex_storybook/story_component.rb @@ -7,6 +7,7 @@ class StoryComponent def initialize(component, location: nil) @component = component @location = location || nil #component.source_location + @filename = nil @name = component.name @category = "Uncategorized" @description = "No description provided" @@ -21,12 +22,19 @@ def default_story .to_h end + def evaluate!(**props) + initializer = PhlexStorybook::Props::ComponentInitializer.create(self, **props) + args = props.blank? ? "" : "(#{initializer.parameters_as_string}\n)" + source = "render #{component.name}.new#{args}".strip + [initializer.initialize_component(component), source] + end + def id_for(title) Digest::MD5.hexdigest(title) end def source - File.read component.instance_method(:view_template).source_location.first + File.read filename end def story_for(id) @@ -40,5 +48,20 @@ def transform_props(user_data) h[k] = props.detect { |prop| prop.prop_for?(k) }&.transform(v) end.compact end + + def update_source(new_source) + # component.class_eval new_source + bytes = File.write(filename, new_source) + load filename + {success: true, bytes: bytes} + rescue StandardError => e + {success: false, error: e} + end + + private + + def filename + @filename = component.instance_method(:view_template).source_location.first + end end end