View Component Contrib

Installs ViewComponent and adds ViewComponent::Contrib extensions and configuration settings
Icons/chart bar
Used 1945 times
Created by
V Vladimir Dementyev

Usage
ViewComponent::Contrib contains extensions, patches and guides for working with ViewComponent.

This template:
  • Installs ViewComponent and view_component-contrib gem.
  • Configures ViewComponent paths.
  • Adds ApplicationViewComponent and ApplicationViewComponentPreview classes.
  • Configures testing framework (RSpec or Minitest).
  • Adds required JS/CSS configuration.
  • Adds a custom generator to create components.

Built by Evil Martians.

Run this command in your Rails app directory in the terminal:

rails app:template LOCATION="https://railsbytes.com/script/zJosO5"
Template Source

Review the code before running this template on your machine.

say "👋 Welcome to interactive ViewComponent installer and configurator. " \
    "Make sure you've read the view_component-contrib guide: https://github.com/palkan/view_component-contrib"

run "bundle add view_component view_component-contrib --skip-install"

say_status :info, "✅ ViewComponent gems added"

DEFAULT_ROOT = "app/frontend/components"

root = ask("Where do you want to store your view components? (default: #{DEFAULT_ROOT})")
ROOT_PATH = root.present? && root.downcase != "n" ? root : DEFAULT_ROOT

root_paths = ROOT_PATH.split("/").map { |path| "\"#{path}\"" }.join(", ")

application "config.view_component.preview_paths << Rails.root.join(#{root_paths})"
application "config.autoload_paths << Rails.root.join(#{root_paths})"

say_status :info, "✅ ViewComponent paths configured"

file "#{ROOT_PATH}/application_view_component.rb",
ERB.new(
    *[
  <<~'TCODE'
class ApplicationViewComponent < ViewComponentContrib::Base
end
  TCODE
  ], trim_mode: "<>").result(binding)
file "#{ROOT_PATH}/application_view_component_preview.rb",
ERB.new(
    *[
  <<~'TCODE'
class ApplicationViewComponentPreview < ViewComponentContrib::Preview::Base
  self.abstract_class = true
end
  TCODE
  ], trim_mode: "<>").result(binding)
say_status :info, "✅ ApplicationViewComponent and ApplicationViewComponentPreview classes added"

USE_RSPEC = File.directory?("spec")
TEST_ROOT_PATH = USE_RSPEC ? File.join("spec", ROOT_PATH.sub("app/", "")) : File.join("test", ROOT_PATH.sub("app/", ""))
TEST_SYSTEM_ROOT_PATH = USE_RSPEC ? File.join("spec", "system", ROOT_PATH.sub("app/", "")) : File.join("test", "system", ROOT_PATH.sub("app/", ""))

USE_DRY = yes? "Would you like to use dry-initializer in your component classes? (y/n)"

if USE_DRY
  run "bundle add dry-initializer --skip-install"

  inject_into_file "#{ROOT_PATH}/application_view_component.rb", "\n  extend Dry::Initializer", after: "class ApplicationViewComponent < ViewComponentContrib::Base"

  say_status :info, "✅ Extended ApplicationViewComponent with Dry::Initializer"
end

initializer "view_component.rb",
ERB.new(
    *[
  <<~'TCODE'
ActiveSupport.on_load(:view_component) do
  # Extend your preview controller to support authentication and other
  # application-specific stuff
  #
  # Rails.application.config.to_prepare do
  #   ViewComponentsController.class_eval do
  #     include Authenticated
  #   end
  # end
  #
  # Make it possible to store previews in sidecar folders
  # See https://github.com/palkan/view_component-contrib#organizing-components-or-sidecar-pattern-extended
  ViewComponent::Preview.extend ViewComponentContrib::Preview::Sidecarable
  # Enable `self.abstract_class = true` to exclude previews from the list
  ViewComponent::Preview.extend ViewComponentContrib::Preview::Abstract
end
  TCODE
  ], trim_mode: "<>").result(binding)
say_status :info, "✅ Added ViewComponent initializer with required patches"

if USE_RSPEC
  inject_into_file "spec/rails_helper.rb", after: "require \"rspec/rails\"\n" do
    "require \"capybara/rspec\"\nrequire \"view_component/test_helpers\"\n"
  end

  inject_into_file "spec/rails_helper.rb", after: "RSpec.configure do |config|\n" do
    <<-CODE
  config.include ViewComponent::TestHelpers, type: :view_component
  config.include Capybara::RSpecMatchers, type: :view_component

  config.define_derived_metadata(file_path: %r{/#{TEST_ROOT_PATH}}) do |metadata|
    metadata[:type] = :view_component
  end

    CODE
  end
end

say_status :info, "✅ RSpec configured"

USE_STIMULUS = yes? "Do you use Stimulus? (y/n)"

if USE_STIMULUS
  say "⚠️  See the discussion on how to configure your JS bundler to auto-load controllers: https://github.com/palkan/view_component-contrib/discussions/14"
end

USE_TAILWIND = yes? "Do you use TailwindCSS? (y/n)"

if USE_TAILWIND
  # TODO: Use styled variants
else
  say "⚠️  Check out PostCSS modules to keep your CSS isolated and closer to your components: https://github.com/palkan/view_component-contrib#isolating-css-with-postcss-modules"
end

if yes?("Would you like to create a custom generator for your setup? (y/n)")
  template_choice_to_ext = {"1" => ".erb", "2" => ".haml", "3" => ".slim"}

  template = ask "Which template processor do you use? (1) ERB, (2) Haml, (3) Slim, (0) Other"

  TEMPLATE_EXT = template_choice_to_ext.fetch(template, "")
  TEST_SUFFIX = USE_RSPEC ? 'spec' : 'test'

  file "lib/generators/view_component/view_component_generator.rb", <<~CODE
  # frozen_string_literal: true

  # Based on https://github.com/github/view_component/blob/master/lib/rails/generators/component/component_generator.rb
  class ViewComponentGenerator < Rails::Generators::NamedBase
    source_root File.expand_path("templates", __dir__)

    class_option :skip_test, type: :boolean, default: false
    class_option :skip_system_test, type: :boolean, default: false
    class_option :skip_preview, type: :boolean, default: false

    argument :attributes, type: :array, default: [], banner: "attribute"

    def create_component_file
      template "component.rb", File.join("#{ROOT_PATH}", class_path, file_name, "component.rb")
    end

    def create_template_file
      template "component.html#{TEMPLATE_EXT}", File.join("#{ROOT_PATH}", class_path, file_name, "component.html#{TEMPLATE_EXT}")
    end

    def create_test_file
      return if options[:skip_test]

      template "component_#{TEST_SUFFIX}.rb", File.join("#{TEST_ROOT_PATH}", class_path, "\#{file_name}_#{TEST_SUFFIX}.rb")
    end

    def create_system_test_file
      return if options[:skip_system_test]

      template "component_system_#{TEST_SUFFIX}.rb", File.join("#{TEST_SYSTEM_ROOT_PATH}", class_path, "\#{file_name}_#{TEST_SUFFIX}.rb")
    end

    def create_preview_file
      return if options[:skip_preview]

      template "preview.rb", File.join("#{ROOT_PATH}", class_path, file_name, "preview.rb")
    end

    private

    def parent_class
      "ApplicationViewComponent"
    end

    def preview_parent_class
      "ApplicationViewComponentPreview"
    end
  end
  CODE

  if USE_DRY
    inject_into_file "lib/generators/view_component/view_component_generator.rb", before: "\nend" do
      <<-CODE


  def initialize_signature
    return if attributes.blank?

    attributes.map { |attr| "option :\#{attr.name}" }.join("\\n  ")
  end
      CODE
    end

    file "lib/generators/view_component/templates/component.rb.tt",
      <<~CODE
        # frozen_string_literal: true

        class <%= class_name %>::Component < <%= parent_class %>
          with_collection_parameter :<%= singular_name %>
        <%- if initialize_signature -%>
          <%= initialize_signature %>
        <%- end -%>
        end
      CODE
  else
    inject_into_file "lib/generators/view_component/view_component_generator.rb", before: "\nend" do
      <<-CODE


  def initialize_signature
    return if attributes.blank?

    attributes.map { |attr| "\#{attr.name}:" }.join(", ")
  end

  def initialize_body
    attributes.map { |attr| "@\#{attr.name} = \#{attr.name}" }.join("\\n    ")
  end
      CODE
    end

    file "lib/generators/view_component/templates/component.rb.tt",
      <<~CODE
        # frozen_string_literal: true

        class <%= class_name %>::Component < <%= parent_class %>
          with_collection_parameter :<%= singular_name %>
        <%- if initialize_signature -%>
          def initialize(<%= initialize_signature %>)
            <%= initialize_body %>
          end
        <%- end -%>
        end
      CODE
  end

  if TEMPLATE_EXT == ".slim"
    file "lib/generators/view_component/templates/component.html.slim.tt", <<~CODE
    div Add <%= class_name %> template here
    CODE
  end

  if TEMPLATE_EXT == ".erb"
    file "lib/generators/view_component/templates/component.html.erb.tt", <<~CODE
    <div>Add <%= class_name %> template here</div>
    CODE
  end

  if TEMPLATE_EXT == ".haml"
    file "lib/generators/view_component/templates/component.html.haml.tt", <<~CODE
    %div Add <%= class_name %> template here
    CODE
  end

  if TEMPLATE_EXT == ""
    file "lib/generators/view_component/templates/component.html.tt", <<~CODE
    <div>Add <%= class_name %> template here</div>
    CODE
  end

  file "lib/generators/view_component/templates/preview.rb.tt", <<~CODE
  # frozen_string_literal: true

  class <%= class_name %>::Preview < <%= preview_parent_class %>
    # You can specify the container class for the default template
    # self.container_class = "w-1/2 border border-gray-300"

    def default
    end
  end
  CODE

  if USE_RSPEC
    file "lib/generators/view_component/templates/component_spec.rb.tt", <<~CODE
  # frozen_string_literal: true

  require "rails_helper"

  describe <%= class_name %>::Component do
    let(:options) { {} }
    let(:component) { <%= class_name %>::Component.new(**options) }

    subject { rendered_content }

    it "renders" do
      render_inline(component)

      is_expected.to have_css "div"
    end
  end
    CODE

    file "lib/generators/view_component/templates/component_system_spec.rb.tt", <<~CODE
    # frozen_string_literal: true

    require "rails_helper"

    describe "<%= file_name %> component" do
      it "default preview" do
        visit("/rails/view_components<%= File.join(class_path, file_name) %>/default")

        # is_expected.to have_text "Hello!"
        # click_on "Click me"
        # is_expected.to have_text "Good-bye!"
      end
    end
      CODE
  else
    file "lib/generators/view_component/templates/component_test.rb.tt", <<~CODE
  # frozen_string_literal: true

  require "test_helper"

  class <%= class_name %>::ComponentTest < ViewComponent::TestCase
    def test_renders
      component = build_component

      render_inline(component)

      assert_selector "div"
    end

    private

    def build_component(**options)
      <%= class_name %>::Component.new(**options)
    end
  end
    CODE

    file "lib/generators/view_component/templates/component_system_test.rb.tt", <<~CODE
  # frozen_string_literal: true

  require "application_system_test_case"

  class <%= class_name %>::ComponentSystemTest < ApplicationSystemTestCase
    def test_default_preview
      visit("/rails/view_components<%= File.join(class_path, file_name) %>/default")

      # assert_text "Hello!"
      # click_on("Click me!")
      # assert_text "Good-bye!"
    end
  end
    CODE
  end

  file "lib/generators/view_component/USAGE", <<~CODE
  Description:
  ============
      Creates a new view component, test and preview files.
      Pass the component name, either CamelCased or under_scored, and an optional list of attributes as arguments.

  Example:
  ========
      bin/rails generate view_component Profile name age

      creates a Profile component and test:
          Component:    #{ROOT_PATH}/profile/component.rb
          Template:     #{ROOT_PATH}/profile/component.html#{TEMPLATE_EXT}
          Test:         #{TEST_ROOT_PATH}/profile_component_#{TEST_SUFFIX}.rb
          System Test:  #{TEST_SYSTEM_ROOT_PATH}/profile_component_#{TEST_SUFFIX}.rb
          Preview:      #{ROOT_PATH}/profile/component_preview.rb
  CODE

  # Check if autoload_lib is configured
  if File.file?("config/application.rb") && File.read("config/application.rb").include?("config.autoload_lib")
    say_status :info, "⚠️  Make sure you configured autoload_lib to ignore the lib/generators folder"
  end
end

say "Installing gems..."

Bundler.with_unbundled_env { run "bundle install" }

say_status :info, "✅  You're ready to rock!"
Comments

Sign up or Login to leave a comment.