├── .rspec ├── CHANGELOG.md ├── lib ├── superview │ ├── version.rb │ ├── helpers │ │ ├── turbo.rb │ │ ├── turbo │ │ │ └── meta_tags.rb │ │ └── links.rb │ ├── components │ │ └── table_component.rb │ ├── actions.rb │ └── assignable.rb └── superview.rb ├── sig └── superview.rbs ├── bin ├── setup └── console ├── .gitignore ├── Rakefile ├── Gemfile ├── spec ├── spec_helper.rb ├── rails_helper.rb └── superview_spec.rb ├── .github └── workflows │ └── main.yml ├── LICENSE.txt ├── superview.gemspec ├── Gemfile.lock ├── CODE_OF_CONDUCT.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [0.1.0] - 2023-08-17 4 | 5 | - Initial release 6 | -------------------------------------------------------------------------------- /lib/superview/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Superview 4 | VERSION = "1.0.1" 5 | end 6 | -------------------------------------------------------------------------------- /sig/superview.rbs: -------------------------------------------------------------------------------- 1 | module Superview 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in superview.gemspec 6 | gemspec 7 | 8 | gem "rake", "~> 13.0" 9 | 10 | gem "rspec-rails", "~> 7.0" 11 | -------------------------------------------------------------------------------- /lib/superview.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "superview/version" 4 | require "active_support/concern" 5 | require "zeitwerk" 6 | 7 | module Superview 8 | Loader = Zeitwerk::Loader.for_gem.tap do |loader| 9 | loader.ignore "#{__dir__}/generators" 10 | loader.setup 11 | end 12 | 13 | class Error < StandardError; end 14 | end 15 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "superview" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "phlex" 3 | require "superview" 4 | 5 | RSpec.configure do |config| 6 | # Enable flags like --only-failures and --next-failure 7 | config.example_status_persistence_file_path = ".rspec_status" 8 | 9 | # Disable RSpec exposing methods globally on `Module` and `main` 10 | config.disable_monkey_patching! 11 | 12 | config.expect_with :rspec do |c| 13 | c.syntax = :expect 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '3.2.1' 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Set up Ruby 22 | uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | bundler-cache: true 26 | - name: Run the default task 27 | run: bundle exec rake 28 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # Load necessary libraries 2 | require "action_controller/railtie" 3 | require "rspec/rails" 4 | require "spec_helper" 5 | require "phlex-rails" 6 | require "view_component" 7 | 8 | # Configure a minimal application for ActionController 9 | class TestApplication < Rails::Application 10 | config.eager_load = false 11 | config.secret_key_base = "test" 12 | # Required to prevent ActionDispatch warnings 13 | config.hosts << "www.example.com" 14 | # Don't log to test output 15 | config.logger = Logger.new(File::NULL) 16 | end 17 | 18 | # Initialize the application 19 | TestApplication.initialize! 20 | 21 | RSpec.configure do |config| 22 | # Enable testing of controllers 23 | config.infer_spec_type_from_file_location! 24 | end 25 | -------------------------------------------------------------------------------- /lib/superview/helpers/turbo.rb: -------------------------------------------------------------------------------- 1 | module Superview 2 | module Helpers 3 | module Turbo 4 | extend ActiveSupport::Concern 5 | 6 | included do 7 | register_element :turbo_cable_stream_source 8 | end 9 | 10 | def turbo_stream_from(*streamables, **attributes) 11 | attributes[:channel] = attributes[:channel]&.to_s || "Turbo::StreamsChannel" 12 | attributes[:"signed-stream-name"] = ::Turbo::StreamsChannel.signed_stream_name(streamables) 13 | turbo_cable_stream_source **attributes, class: "hidden", style: "display: none;" 14 | end 15 | 16 | def stream_from(*streamables) 17 | streamables.each do |streamable| 18 | case streamable 19 | in association: ActiveRecord::Relation 20 | association.each { turbo_stream_from streamable } 21 | else 22 | turbo_stream_from streamable 23 | end 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Brad Gessler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /superview.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/superview/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "superview" 7 | spec.version = Superview::VERSION 8 | spec.authors = ["Brad Gessler"] 9 | spec.email = ["bradgessler@gmail.com"] 10 | 11 | spec.summary = "Build Rails applications entirely from Phlex, ViewComponents, or any object that responds to `#render_in`" 12 | spec.description = spec.summary 13 | spec.homepage = "https://github.com/rubymonolith/superview" 14 | spec.license = "MIT" 15 | spec.required_ruby_version = ">= 2.6.0" 16 | 17 | spec.metadata["allowed_push_host"] = "https://rubygems.org/" 18 | 19 | spec.metadata["homepage_uri"] = spec.homepage 20 | spec.metadata["source_code_uri"] = spec.homepage 21 | spec.metadata["changelog_uri"] = spec.homepage 22 | 23 | # Specify which files should be added to the gem when it is released. 24 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 25 | spec.files = Dir.chdir(__dir__) do 26 | `git ls-files -z`.split("\x0").reject do |f| 27 | (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor]) 28 | end 29 | end 30 | spec.bindir = "exe" 31 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 32 | spec.require_paths = ["lib"] 33 | 34 | # Uncomment to register a new dependency of your gem 35 | spec.add_dependency "zeitwerk", "~> 2.0" 36 | 37 | # For more information and examples about making a new gem, check out our 38 | # guide at: https://bundler.io/guides/creating_gem.html 39 | spec.add_development_dependency "view_component", "~> 3.0.0" 40 | spec.add_development_dependency "phlex-rails", ">= 1.0", "< 3.0" 41 | end 42 | -------------------------------------------------------------------------------- /lib/superview/helpers/turbo/meta_tags.rb: -------------------------------------------------------------------------------- 1 | module Superview 2 | module Helpers 3 | module Turbo 4 | # Renders the metatags for setting up Turbo Drive. 5 | class MetaTags < Phlex::HTML 6 | attr_accessor \ 7 | :method, 8 | :scroll, 9 | :exempts_page_from_cache, 10 | :exempts_page_from_preview, 11 | :page_requires_reload 12 | 13 | METHOD = :replace 14 | SCROLL = :reset 15 | 16 | def initialize(method: METHOD, scroll: SCROLL, exempts_page_from_preview: nil, exempts_page_from_cache: nil, page_requires_reload: nil) 17 | refreshes_with method: method, scroll: scroll 18 | @exempts_page_from_cache = exempts_page_from_cache 19 | @exempts_page_from_preview = exempts_page_from_preview 20 | @page_requires_reload = page_requires_reload 21 | end 22 | 23 | def view_template 24 | meta(name: "turbo-refresh-method", content: @method) 25 | meta(name: "turbo-refresh-scroll", content: @scroll) 26 | meta(name: "turbo-cache-control", content: "no-cache") if @exempts_page_from_cache 27 | meta(name: "turbo-cache-control", content: "no-preview") if @exempts_page_from_preview 28 | meta(name: "turbo-visit-control", content: "reload") if @page_requires_reload 29 | end 30 | 31 | def refreshes_with(method: METHOD, scroll: SCROLL) 32 | self.method = method 33 | self.scroll = scroll 34 | end 35 | 36 | def method=(value) 37 | raise ArgumentError, "Invalid refresh option '#{value}'" unless value.in?(%i[ replace morph ]) 38 | @method = value 39 | end 40 | 41 | def scroll=(value) 42 | raise ArgumentError, "Invalid scroll option '#{value}'" unless value.in?(%i[ reset preserve ]) 43 | @scroll = value 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/superview/components/table_component.rb: -------------------------------------------------------------------------------- 1 | module Superview::Components 2 | # Renders an HTML table for a collection. Each item is passed into the 3 | # collection of the table. 4 | # 5 | # ```ruby 6 | # render TableComponent.new(@posts) do |table| 7 | # # This is how you'd usually render a table. 8 | # table.column("Title") { show(_1, :title) } 9 | # 10 | # # If you need to render HTML in the title, add a `column` argument 11 | # # to the block and call `title` or `item` on it. 12 | # table.column do |column| 13 | # # Titles might not always be text, so we need to handle rendering 14 | # # Phlex markup within. 15 | # column.title do 16 | # link_to(user_blogs_path(@current_user)) { "Blogs" } 17 | # end 18 | # column.item { show(_1.blog, :title) } 19 | # end 20 | # end 21 | # ``` 22 | 23 | class TableComponent < Phlex::HTML 24 | if Phlex.const_defined?(:DeferredRender) 25 | # Phlex 1.0 26 | include Phlex::DeferredRender 27 | else 28 | # Phlex 2.0 29 | def before_template(&) 30 | vanish(&) 31 | super 32 | end 33 | end 34 | 35 | class Column 36 | attr_accessor :title_template, :item_template 37 | 38 | def title(&block) 39 | @title_template = block 40 | end 41 | 42 | def item(&block) 43 | @item_template = block 44 | end 45 | 46 | def self.build(title:, &block) 47 | new.tap do |column| 48 | column.title { title } 49 | column.item(&block) 50 | end 51 | end 52 | end 53 | 54 | def initialize(items = [], **attributes) 55 | @items = items 56 | @attributes = attributes 57 | @columns = [] 58 | end 59 | 60 | def view_template(&) 61 | table(**@attributes) do 62 | thead do 63 | tr do 64 | @columns.each do |column| 65 | th(&column.title_template) 66 | end 67 | end 68 | end 69 | tbody do 70 | @items.each do |item| 71 | tr do 72 | @columns.each do |column| 73 | td { column.item_template.call(item) } 74 | end 75 | end 76 | end 77 | end 78 | end 79 | end 80 | 81 | def column(title = nil, &block) 82 | @columns << if title 83 | Column.build(title: title, &block) 84 | else 85 | Column.new.tap(&block) 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/superview/helpers/links.rb: -------------------------------------------------------------------------------- 1 | module Superview 2 | module Helpers 3 | # RESTful links for creating Superviews in applications. For example, given a 4 | # blog application, we might have links like: 5 | # 6 | # ```ruby 7 | # create(Post) { "New Blog Post" } 8 | # create(Post.new) { "New Blog Post" } 9 | # ``` 10 | # 11 | # Which generateds the html `New Blog Post` and 12 | # 13 | # ```ruby 14 | # show(@post) { @post.title } 15 | # ``` 16 | # 17 | # generates the html `My First Post`. An attribute 18 | # can be passed in as a second argument, which calls the method on the object 19 | # passed into the link helper. 20 | # 21 | # ```ruby 22 | # show(@post, :title) 23 | # ``` 24 | # 25 | # generates `New Blog Post`. 26 | # 27 | # Link helpers are available per RESTful action. 28 | # 29 | # ```ruby 30 | # delete(@post) 31 | # edit(@post) 32 | # ``` 33 | module Links 34 | # Give us some sane link helpers to work with in Phlex. They kind 35 | # of mimic Rails helpers, but are "Phlexable". 36 | def link_to(target = nil, method: nil, **attributes, &) 37 | url = case target 38 | when URI 39 | target.to_s 40 | when NilClass 41 | url_for(attributes) 42 | else 43 | url_for(target) 44 | end 45 | a(href: url, data_turbo_method: method, **attributes, &) 46 | end 47 | 48 | def show(model, attribute = nil, *args, **kwargs, &content) 49 | content ||= Proc.new { model.send(attribute) } 50 | link_to(model, *args, **kwargs, &content) 51 | end 52 | 53 | def edit(model, *args, **kwargs, &content) 54 | content ||= Proc.new { "Edit #{model.class.model_name}" } 55 | link_to([:edit, model], *args, **kwargs, &content) 56 | end 57 | 58 | def delete(model, *args, confirm: nil, **kwargs, &content) 59 | content ||= Proc.new { "Delete #{model.class.model_name}" } 60 | link_to(model, *args, method: :delete, data_turbo_confirm: confirm, **kwargs, &content) 61 | end 62 | 63 | def create(scope = nil, *args, **kwargs, &content) 64 | target = if scope.respond_to? :proxy_association 65 | owner = scope.proxy_association.owner 66 | model = scope.proxy_association.reflection.klass.model_name 67 | element = scope.proxy_association.reflection.klass.model_name.element.to_sym 68 | [:new, owner, element] 69 | elsif scope.respond_to? :model 70 | model = scope.model 71 | [:new, model.model_name.singular_route_key.to_sym] 72 | elsif scope.respond_to? :model_name 73 | [:new, scope.model_name.singular_route_key.to_sym] 74 | end 75 | 76 | content ||= Proc.new { "Create #{model}" } 77 | 78 | link_to(target, *args, **kwargs, &content) 79 | end 80 | end 81 | end 82 | end -------------------------------------------------------------------------------- /spec/superview_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | # Define a test controller 4 | class PostsController < ActionController::Base 5 | include Superview::Actions 6 | 7 | before_action :load_post 8 | 9 | class Show < Phlex::HTML 10 | attr_writer :post 11 | 12 | def view_template 13 | h1 { @post.title } 14 | div(class: "prose") { @post.body } 15 | end 16 | end 17 | 18 | class New < Phlex::HTML 19 | def view_template 20 | raise "Should never hit this" 21 | end 22 | end 23 | 24 | class Edit < ViewComponent::Base 25 | attr_writer :post 26 | 27 | def call 28 | <<~HTML 29 | h1 { "Edit #{@post.title}" } 30 | div(class: "prose") { "#{@post.body}" } 31 | HTML 32 | end 33 | end 34 | 35 | def new 36 | render inline: "Don't hit new" 37 | end 38 | 39 | def a_class 40 | render phlex Show 41 | end 42 | 43 | def an_instance 44 | render phlex Show.new 45 | end 46 | 47 | def a_string 48 | render phlex "show" 49 | end 50 | 51 | def a_symbol 52 | render phlex :show 53 | end 54 | 55 | private 56 | 57 | def load_post 58 | @post = Struct.new(:title, :body).new("Test Post", "This is a test body.") 59 | end 60 | end 61 | 62 | RSpec.describe PostsController, type: :controller do 63 | # Define routes for testing 64 | before do 65 | Rails.application.routes.draw do 66 | get "posts/show", to: "posts#show" 67 | get "posts/new", to: "posts#new" 68 | get "posts/edit", to: "posts#edit" 69 | get "posts/a_class", to: "posts#a_class" 70 | get "posts/an_instance", to: "posts#an_instance" 71 | get "posts/a_string", to: "posts#a_string" 72 | get "posts/a_symbol", to: "posts#a_symbol" 73 | end 74 | end 75 | 76 | after do 77 | Rails.application.routes_reloader.reload! 78 | end 79 | 80 | # Test the action 81 | describe "GET #show" do 82 | it "renders the Phlex view for the action" do 83 | get :show 84 | expect(response.body).to include("Test Post") 85 | expect(response.body).to include("This is a test body.") 86 | end 87 | end 88 | 89 | describe "GET #new" do 90 | it "renders the action" do 91 | get :new 92 | expect(response.body).to include("Don't hit new") 93 | end 94 | end 95 | 96 | describe "GET #edit" do 97 | it "renders the Phlex view for the action" do 98 | get :edit 99 | expect(response.body).to include("Edit Test Post") 100 | expect(response.body).to include("This is a test body.") 101 | end 102 | end 103 | 104 | describe "GET #a_class" do 105 | it "renders the Phlex view for the action" do 106 | get :a_class 107 | expect(response.body).to include("Test Post") 108 | end 109 | end 110 | 111 | describe "GET #an_instance" do 112 | it "renders the Phlex view for the action" do 113 | get :an_instance 114 | expect(response.body).to include("Test Post") 115 | end 116 | end 117 | 118 | describe "GET #a_string" do 119 | it "renders the Phlex view for the action" do 120 | get :a_string 121 | expect(response.body).to include("Test Post") 122 | end 123 | end 124 | 125 | describe "GET #a_symbol" do 126 | it "renders the Phlex view for the action" do 127 | get :a_symbol 128 | expect(response.body).to include("Test Post") 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | superview (1.0.1) 5 | zeitwerk (~> 2.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | actionpack (7.2.2.1) 11 | actionview (= 7.2.2.1) 12 | activesupport (= 7.2.2.1) 13 | nokogiri (>= 1.8.5) 14 | racc 15 | rack (>= 2.2.4, < 3.2) 16 | rack-session (>= 1.0.1) 17 | rack-test (>= 0.6.3) 18 | rails-dom-testing (~> 2.2) 19 | rails-html-sanitizer (~> 1.6) 20 | useragent (~> 0.16) 21 | actionview (7.2.2.1) 22 | activesupport (= 7.2.2.1) 23 | builder (~> 3.1) 24 | erubi (~> 1.11) 25 | rails-dom-testing (~> 2.2) 26 | rails-html-sanitizer (~> 1.6) 27 | activesupport (7.2.2.1) 28 | base64 29 | benchmark (>= 0.3) 30 | bigdecimal 31 | concurrent-ruby (~> 1.0, >= 1.3.1) 32 | connection_pool (>= 2.2.5) 33 | drb 34 | i18n (>= 1.6, < 2) 35 | logger (>= 1.4.2) 36 | minitest (>= 5.1) 37 | securerandom (>= 0.3) 38 | tzinfo (~> 2.0, >= 2.0.5) 39 | base64 (0.2.0) 40 | benchmark (0.4.0) 41 | bigdecimal (3.1.8) 42 | builder (3.3.0) 43 | concurrent-ruby (1.3.4) 44 | connection_pool (2.4.1) 45 | crass (1.0.6) 46 | date (3.4.1) 47 | diff-lcs (1.5.1) 48 | drb (2.2.1) 49 | erubi (1.13.0) 50 | i18n (1.14.6) 51 | concurrent-ruby (~> 1.0) 52 | io-console (0.8.0) 53 | irb (1.14.2) 54 | rdoc (>= 4.0.0) 55 | reline (>= 0.4.2) 56 | logger (1.6.3) 57 | loofah (2.23.1) 58 | crass (~> 1.0.2) 59 | nokogiri (>= 1.12.0) 60 | method_source (1.1.0) 61 | minitest (5.25.4) 62 | nokogiri (1.18.0-arm64-darwin) 63 | racc (~> 1.4) 64 | nokogiri (1.18.0-x86_64-linux-gnu) 65 | racc (~> 1.4) 66 | phlex (1.11.0) 67 | phlex-rails (1.2.2) 68 | phlex (>= 1.10, < 2) 69 | railties (>= 6.1, < 9) 70 | psych (5.2.1) 71 | date 72 | stringio 73 | racc (1.8.1) 74 | rack (3.1.8) 75 | rack-session (2.0.0) 76 | rack (>= 3.0.0) 77 | rack-test (2.1.0) 78 | rack (>= 1.3) 79 | rackup (2.2.1) 80 | rack (>= 3) 81 | rails-dom-testing (2.2.0) 82 | activesupport (>= 5.0.0) 83 | minitest 84 | nokogiri (>= 1.6) 85 | rails-html-sanitizer (1.6.2) 86 | loofah (~> 2.21) 87 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 88 | railties (7.2.2.1) 89 | actionpack (= 7.2.2.1) 90 | activesupport (= 7.2.2.1) 91 | irb (~> 1.13) 92 | rackup (>= 1.0.0) 93 | rake (>= 12.2) 94 | thor (~> 1.0, >= 1.2.2) 95 | zeitwerk (~> 2.6) 96 | rake (13.2.1) 97 | rdoc (6.9.0) 98 | psych (>= 4.0.0) 99 | reline (0.5.12) 100 | io-console (~> 0.5) 101 | rspec-core (3.13.2) 102 | rspec-support (~> 3.13.0) 103 | rspec-expectations (3.13.3) 104 | diff-lcs (>= 1.2.0, < 2.0) 105 | rspec-support (~> 3.13.0) 106 | rspec-mocks (3.13.2) 107 | diff-lcs (>= 1.2.0, < 2.0) 108 | rspec-support (~> 3.13.0) 109 | rspec-rails (7.1.0) 110 | actionpack (>= 7.0) 111 | activesupport (>= 7.0) 112 | railties (>= 7.0) 113 | rspec-core (~> 3.13) 114 | rspec-expectations (~> 3.13) 115 | rspec-mocks (~> 3.13) 116 | rspec-support (~> 3.13) 117 | rspec-support (3.13.2) 118 | securerandom (0.4.0) 119 | stringio (3.1.2) 120 | thor (1.3.2) 121 | tzinfo (2.0.6) 122 | concurrent-ruby (~> 1.0) 123 | useragent (0.16.11) 124 | view_component (3.0.0) 125 | activesupport (>= 5.2.0, < 8.0) 126 | concurrent-ruby (~> 1.0) 127 | method_source (~> 1.0) 128 | zeitwerk (2.7.1) 129 | 130 | PLATFORMS 131 | arm64-darwin-22 132 | arm64-darwin-23 133 | arm64-darwin-24 134 | x86_64-linux 135 | 136 | DEPENDENCIES 137 | phlex-rails (>= 1.0, < 3.0) 138 | rake (~> 13.0) 139 | rspec-rails (~> 7.0) 140 | superview! 141 | view_component (~> 3.0.0) 142 | 143 | BUNDLED WITH 144 | 2.4.8 145 | -------------------------------------------------------------------------------- /lib/superview/actions.rb: -------------------------------------------------------------------------------- 1 | module Superview 2 | # Include in controllers to map action names to class names. This makes it possible to 3 | # embed Phlex components directly into Rails controllers without having to go through 4 | # other templating systems like Erb. 5 | # 6 | # Instance methods will be assigned to views that have `attr_accessor` methods. 7 | # 8 | # Consider a blog post controller: 9 | # 10 | # ```ruby 11 | # class PostsController < ApplicationController 12 | # include Superview::Actions 13 | # 14 | # before_action :load_post 15 | # 16 | # class Show < Phlex::HTML 17 | # attr_accessor :post 18 | # 19 | # def view_template(&) 20 | # h1 { @post.title } 21 | # div(class: "prose") { @post.body } 22 | # end 23 | # end 24 | # 25 | # private 26 | # def load_post 27 | # @post = Post.find(params[:id]) 28 | # end 29 | # end 30 | # ``` 31 | # 32 | # The `@post` variable gets set in the `Show` view class via `Show#post=`. 33 | module Actions 34 | extend ActiveSupport::Concern 35 | 36 | class_methods do 37 | # Finds a class on the controller with the same name as the action. For example, 38 | # `def index` would find the `Index` constant on the controller class to render 39 | # for the action `index`. 40 | def component_action_class(action:) 41 | action_class = action.to_s.camelcase 42 | const_get action_class if const_defined? action_class 43 | end 44 | end 45 | 46 | protected 47 | 48 | # Assigns the instance variables that are set in the controller to setter method 49 | # on Phlex. For example, if a controller defines @users and a Phlex class has 50 | # `attr_writer :users`, `attr_accessor :user`, or `def users=`, it will be automatically 51 | # set by this method. 52 | def assign_component_accessors(view) 53 | view.tap do |view| 54 | view_assigns.each do |variable, value| 55 | attr_writer_name = "#{variable}=" 56 | view.send attr_writer_name, value if view.respond_to? attr_writer_name 57 | end 58 | end 59 | end 60 | 61 | # Initializers a Phlex view based on the action name and assigns accessors 62 | def component_action(action) 63 | component_view self.class.component_action_class(action: action) 64 | end 65 | 66 | # Initializes a component view class and assigns accessors. 67 | def component_view(view_class) 68 | assign_component_accessors view_class.new 69 | end 70 | 71 | # Phlex action for the current action. 72 | def component(target = action_name) 73 | if target.is_a? Class 74 | component_view target 75 | elsif target.respond_to? :render_in 76 | assign_component_accessors target 77 | else 78 | component_action target 79 | end 80 | end 81 | 82 | alias :phlex :component 83 | 84 | # Checks if a Phlex class name is present for a controller action name 85 | def component_action_exists?(action) 86 | self.class.component_action_class(action: action).present? 87 | end 88 | 89 | # This is a built-in Rails method resolves the method to call for an action. 90 | # If it resolves a Phlex class in the controller, it will render that. If it's 91 | # not found it continues with Rails method of resolving action names. 92 | def method_for_action(action_name) 93 | # Yep. `super` calls this, but we have to call it here to cover the 94 | # situation where the person uas a `./app/views/users/*.rb` file with a Ruby 95 | # class in it that might be included in the controller via `include Views::Users`. 96 | # If we call super first, it will tink that `./app/views/users/show.rb` is a template 97 | # and raise an error that it doesn't have a format. 98 | if action_method?(action_name) 99 | action_name 100 | elsif component_action_exists? action_name 101 | "default_component_render" 102 | else 103 | super 104 | end 105 | end 106 | 107 | # Renders a Phlex view for the given action, if it's present. 108 | def default_component_render 109 | render component 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or 22 | advances of any kind 23 | * Trolling, insulting or derogatory comments, and personal or political attacks 24 | * Public or private harassment 25 | * Publishing others' private information, such as a physical or email 26 | address, without their explicit permission 27 | * Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at bradgessler@gmail.com. All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at 84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | -------------------------------------------------------------------------------- /lib/superview/assignable.rb: -------------------------------------------------------------------------------- 1 | module Superview 2 | module Assignable 3 | # Include in RESTful Rails controllers to assign instance variables ActiveRecord scopes 4 | # without all the boiler plate. 5 | # 6 | # Let's start with the most simple example 7 | # 8 | # ```ruby 9 | # ./app/controllers/blog/posts_controller.rb 10 | # class BlogsController < ApplicationController 11 | # assign :blog 12 | # end 13 | # ``` 14 | # 15 | # This would load the `Blog` scope for `collection` routes, like `index`, as `@blogs` and 16 | # load `Blog.find(params[:id])` for `member` routes, like `show`, `edit`, `update`, etc. 17 | # as `@blog`. 18 | # 19 | # Most applications need to load stuff from a user that's logged in, which is what the `from:` 20 | # key makes possible. 21 | # 22 | # ```ruby 23 | # ./app/controllers/blog/posts_controller.rb 24 | # class BlogsController < ApplicationController 25 | # assign :blog, from: :current_user 26 | # end 27 | # ``` 28 | # 29 | # This assumes the controller has a `current_user` method defined in the controller that returns 30 | # a `User` model with the relationship `has_many :blogs`. It loads the blog via 31 | # `current_user.blogs.find(params[:id]). 32 | # 33 | # A blog has many posts, so how would we assign a post through a blog from the current user? 34 | # 35 | # ```ruby 36 | # ./app/controllers/blog/posts_controller.rb 37 | # class Blog::PostsController < ApplicationController 38 | # assign :post, through: :blog, from: :current_user 39 | # end 40 | # ``` 41 | # 42 | # This does not work like the `through:` ActiveRecord key, so pay attention ya know it all! This 43 | # follows the idea of nested REST routes in Rails. In this case the `Blog` is the "parent resource" 44 | # and the `Post` is the resource. How does is that queried? Glad you asked! 45 | # 46 | # First the specific blog is loaded via `@blog = current_user.blogs.find(params[:blog_id])` to set the 47 | # parent model. Next the `Post` scope is set via `@posts = @blog.posts`. `@posts` for collection routes. 48 | # Finally `@post = @posts.find(params[:id])` is set for member routes. 49 | 50 | extend ActiveSupport::Concern 51 | 52 | included do 53 | class_attribute :model, :parent_model, :context_method_name 54 | 55 | before_action :assign_parent_collection, if: :has_parent_model? 56 | before_action :assign_parent_member, if: :has_parent_model_instance? 57 | before_action :assign_collection, if: :has_model? 58 | before_action :assign_member, if: :has_model? 59 | end 60 | 61 | protected 62 | 63 | def assign_collection 64 | instance_variable_set "@#{model.model_name.plural}", model_scope 65 | end 66 | 67 | def assign_parent_collection 68 | instance_variable_set "@#{parent_model.model_name.plural}", parent_model_scope 69 | end 70 | 71 | def model_association 72 | if has_parent_model_instance? 73 | parent_model_instance.association(model.model_name.collection) 74 | elsif has_assignable_context? 75 | assignable_context.association(model.model_name.collection) 76 | end 77 | end 78 | 79 | def model_scope 80 | if association = model_association 81 | association.scope 82 | else 83 | model.scope_for_association 84 | end 85 | end 86 | 87 | def parent_model_association 88 | if has_assignable_context? 89 | assignable_context.association(parent_model.model_name.collection) 90 | end 91 | end 92 | 93 | def parent_model_scope 94 | if association = parent_model_association 95 | association.scope 96 | else 97 | parent_model.scope_for_association 98 | end 99 | end 100 | 101 | def parent_model_instance 102 | parent_model_scope.find(params.fetch(parent_model_param_key)) 103 | end 104 | 105 | def assign_parent_member 106 | instance_variable_set "@#{parent_model.model_name.singular}", parent_model_instance 107 | end 108 | 109 | def has_parent_model? 110 | parent_model.present? 111 | end 112 | 113 | def has_parent_model_instance? 114 | has_parent_model? && params.key?(parent_model_param_key) 115 | end 116 | 117 | def has_model? 118 | model.present? 119 | end 120 | 121 | def assign_member 122 | instance_variable_set "@#{model.model_name.singular}", model_instance 123 | end 124 | 125 | def model_instance 126 | if member? 127 | model_scope.find params.fetch(model_param_key) 128 | else 129 | model_scope.build.tap do |post| 130 | # # Blog is a reflection of User 131 | # # Get the name of the `user` association. 132 | # parent_from_association = parent_model_scope.reflection.inverse_of 133 | 134 | # if model.reflect_on_association(parent_from_association.name) 135 | # similar_association = model.association parent_from_association.name 136 | # # Now let's see if that association exists on the current_model .. 137 | # # 138 | # # This isn't setting the foreign key ... errrggggg. 139 | # raise 'hell' 140 | 141 | # # post.association(association_name).target = parent_model_scope.owner 142 | # end 143 | end 144 | end 145 | end 146 | 147 | def member? 148 | params.key? model_param_key 149 | end 150 | 151 | def model_param_key 152 | :id 153 | end 154 | 155 | def parent_model_param_key 156 | "#{parent_model.model_name.singular}_id".to_sym 157 | end 158 | 159 | def assignable_context 160 | self.send self.class.context_method_name 161 | end 162 | 163 | def has_assignable_context? 164 | !!self.class.context_method_name 165 | end 166 | 167 | class_methods do 168 | def assign(scope, through: nil, from: nil) 169 | self.model = Assignable.find_scope scope 170 | self.parent_model = Assignable.find_scope through 171 | self.context_method_name = from 172 | end 173 | end 174 | 175 | def self.find_scope(name) 176 | name.to_s.singularize.camelize.constantize if name 177 | end 178 | end 179 | end -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Superview 2 | 3 | Build Rails applications, from the ground up, using [Phlex](https://www.phlex.fun/) or [ViewComponent](https://viewcomponent.org/) components, like this. 4 | 5 | ```ruby 6 | # ./app/controllers/posts_controller.rb 7 | class PostsController < ApplicationController 8 | include Superview::Actions 9 | 10 | before_action :load_post 11 | 12 | class Show < Components::Base 13 | attr_accessor :post 14 | 15 | def view_template(&) 16 | h1 { @post.title } 17 | div(class: "prose") { @post.body } 18 | end 19 | end 20 | 21 | class Edit < ViewComponent::Base 22 | attr_accessor :post 23 | 24 | def call 25 | <<~HTML 26 |

Edit #{@post.title}

27 |
28 | 29 | 30 | 31 |
32 | HTML 33 | end 34 | end 35 | 36 | private 37 | def load_post 38 | @post = Post.find(params[:id]) 39 | end 40 | end 41 | ``` 42 | 43 | Read more about it at: 44 | 45 | * [Component driven development on Rails with Phlex](https://fly.io/ruby-dispatch/component-driven-development-on-rails-with-phlex/) 46 | * [Hacking Rails Implicit Rendering for View Components & Fun](https://fly.io/ruby-dispatch/hacking-rails-implicit-rendering-for-view-components/) 47 | 48 | ## Installation 49 | 50 | Install the gem and add to the application's Gemfile by executing: 51 | 52 | $ bundle add superview 53 | 54 | If bundler is not being used to manage dependencies, install the gem by executing: 55 | 56 | $ gem install superview 57 | 58 | ## Usage 59 | 60 | Add `include Superview::Actions` to any controllers you'd like to render components as controller actions. 61 | 62 | ```ruby 63 | # ./app/controllers/posts_controller.rb 64 | class PostsController < ApplicationController 65 | # 🚨 Add this 👇 to your controller 🚨 66 | include Superview::Actions 67 | 68 | # Your code... 69 | end 70 | ``` 71 | 72 | Then add classes to your controller that map to the actions you'd like to render. The `Show` class will render when the `PostsController#show` action is called and the `Edit` class will render when the `PostsController#edit` action is called. 73 | 74 | ```ruby 75 | # ./app/controllers/posts_controller.rb 76 | class PostsController < ApplicationController 77 | include Superview::Actions 78 | 79 | before_action :load_post 80 | 81 | class Show < Components::Base 82 | attr_accessor :post 83 | 84 | def view_template(&) 85 | h1 { @post.title } 86 | div(class: "prose") { @post.body } 87 | end 88 | end 89 | 90 | class Edit < ViewComponent::Base 91 | attr_accessor :post 92 | 93 | def call 94 | <<~HTML 95 |

Edit #{@post.title}

96 |
97 | 98 | 99 | 100 |
101 | HTML 102 | end 103 | end 104 | 105 | private 106 | def load_post 107 | @post = Post.find(params[:id]) 108 | end 109 | end 110 | ``` 111 | 112 | ### Explicit rendering 113 | 114 | You can explicitly render a component in a controller action method. In this example, we needed to render a the `Show` component in the `html` format and a JSON response in the `json` format. 115 | 116 | ```ruby 117 | # ./app/controllers/posts_controller.rb 118 | class PostsController < ApplicationController 119 | include Superview::Actions 120 | 121 | # Your code... 122 | 123 | class Show < Components::Base 124 | attr_accessor :post 125 | 126 | def view_template(&) 127 | h1 { @post.title } 128 | div(class: "prose") { @post.body } 129 | end 130 | end 131 | 132 | def show 133 | respond_to do |format| 134 | # 👋 Renders the Show component 135 | format.html { render component } 136 | 137 | # 👉 These would also work... 138 | # format.html { render Show.new.tap { _1.post = @post } } 139 | # format.html { render component Show.new } 140 | # format.html { render component Show } 141 | # format.html { render component :show } 142 | format.json { render json: @post } 143 | end 144 | end 145 | 146 | # Your code... 147 | end 148 | ``` 149 | 150 | ### Rendering other classes from different actions 151 | 152 | It's common to have to render form actions from other actions when forms are saved. In this example the `create` method renders the `component New` view when the form is invalid. 153 | 154 | ```ruby 155 | # ./app/controllers/posts_controller.rb 156 | class PostsController < ApplicationController 157 | include Superview::Actions 158 | 159 | def create 160 | @post = Post.new(post_params) 161 | 162 | if @post.save 163 | redirect_to @post 164 | else 165 | # 👋 Renders the New component from the create action. 166 | render component New 167 | 168 | # 👉 These would also work... 169 | # render New.new.tap { _1.post = @post } 170 | # render component New.new 171 | # render component New 172 | # render component :new 173 | end 174 | end 175 | 176 | # Your code... 177 | end 178 | ``` 179 | 180 | ### Extracting inline views into the `./app/views` folder 181 | 182 | Inline views are an amazingly productive way of prototyping apps, but as it matures you might be inclined to extract these views into the `./app/views` folders for organizational purposes or so you can share them between controllers. 183 | 184 | First let's extract the `Show` class into `./app/views/posts/show.rb` 185 | 186 | ```ruby 187 | # ./app/views/posts/show.rb 188 | module Posts 189 | class Show < Components::Base 190 | attr_accessor :post 191 | 192 | def view_template(&) 193 | h1 { @post.title } 194 | div(class: "prose") { @post.body } 195 | end 196 | end 197 | end 198 | ``` 199 | 200 | Then include the `Posts` module in the controllers you'd like to use the views: 201 | 202 | ```ruby 203 | # ./app/controllers/posts_controller.rb 204 | class PostsController < ApplicationController 205 | include Superview::Actions 206 | # 🚨 Add this 👇 to your controller 🚨 207 | include Posts 208 | 209 | before_action :load_post 210 | 211 | def show 212 | respond_to do |format| 213 | format.html { render Show.new.tap { _1.post = @post } } 214 | format.json { render json: @post } 215 | end 216 | end 217 | 218 | private 219 | def load_post 220 | @post = Post.find(params[:id]) 221 | end 222 | end 223 | ``` 224 | 225 | That's it! Ruby includes all the classes in the `Posts` module, which Superview picks up and renders in the controller. If you have an `Index`, `Edit`, `New`, etc. class in the `Posts` namespace, those would be implicitly rendered for their respective action. 226 | 227 | ### View path class mappings 228 | 229 | Not all component libraries are integrated into Rails views, so you might have to manually configure the view paths in your Rails application. This instructs the Rails code reloader, Zeitwerk, to load the components. 230 | 231 | ```ruby 232 | # ./config/application.rb 233 | module MyApp 234 | class Application < Rails::Application 235 | config.autoload_paths << "#{root}/app/views" 236 | config.autoload_paths << "#{root}/app/views/layouts" 237 | config.autoload_paths << "#{root}/app/views/components" 238 | # Your code 239 | end 240 | end 241 | ``` 242 | 243 | For example, the `Show` component in the `Posts` module would be loaded from `./app/views/posts/show.rb` and the `Layout` component in the `Layouts` module would be loaded from `./app/views/layouts/layout.rb`. 244 | 245 | ## Development 246 | 247 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 248 | 249 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 250 | 251 | ## Contributing 252 | 253 | Bug reports and pull requests are welcome on GitHub at https://github.com/rubymonolith/superview. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/rubymonolith/superview/blob/main/CODE_OF_CONDUCT.md). 254 | 255 | ## License 256 | 257 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 258 | 259 | ## Code of Conduct 260 | 261 | Everyone interacting in the Superview project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/rubymonolith/superview/blob/main/CODE_OF_CONDUCT.md). 262 | --------------------------------------------------------------------------------