View Component Contrib

Installs ViewComponent and adds ViewComponent::Contrib extensions and configuration settings
Icons/chart bar
Used 984 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"

inject_into_file "config/application.rb", "\nrequire \"view_component/engine\"\n", before: "\nBundler.require(*Rails.groups)"

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/", ""))

USE_DRY = yes? "Would you like to use dry-initializer in your component classes?"

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_WEBPACK = File.directory?("config/webpack") || File.file?("webpack.config.js")

if USE_WEBPACK
  USE_STIMULUS = yes? "Do you use StimulusJS?"

  if USE_STIMULUS
    file "#{ROOT_PATH}/index.js",
    ERB.new(
    *[
  <<~'TCODE'
// IMPORTANT: Update this import to reflect the location of your Stimulus application
// See https://github.com/palkan/view_component-contrib#using-with-stimulusjs
import { application } from "../init/stimulus";

const context = require.context(".", true, /index.js$/)
context.keys().forEach((path) => {
  const mod = context(path);

  // Check whether a module has the Controller export defined
  if (!mod.Controller) return;

  // Convert path into a controller identifier:
  //   example/index.js -> example
  //   nav/user_info/index.js -> nav--user-info
  const identifier = path.replace(/^\\.\\//, '')
    .replace(/\\/index\\.js$/, '')
    .replace(/\\//g, '--');

  application.register(identifier, mod.Controller);
});
  TCODE
  ], trim_mode: "<>").result(binding)

    inject_into_file "#{ROOT_PATH}/application_view_component.rb", before: "\nend" do
      ERB.new(
    *[
  <<~'TCODE'


  private

  def identifier
    @identifier ||= self.class.name.sub("::Component", "").underscore.split("/").join("--")
  end
  TCODE
  ], trim_mode: "<>").result(binding)
    end
  else
    file "#{ROOT_PATH}/index.js",
    ERB.new(
    *[
  <<~'TCODE'
const context = require.context(".", true, /index.js$/)
context.keys().forEach(context);
  TCODE
  ], trim_mode: "<>").result(binding)
  end

  say_status :info, "✅ Added index.js to load components JS/CSS"
  say "⚠️   Don't forget to import component JS/CSS (#{ROOT_PATH}/index.js) from your application.js entrypoint"

  say "⚠️   Don't forget to add #{ROOT_PATH} to `additional_paths` in your `webpacker.yml` (unless your `source_path` already includes it)"

  USE_POSTCSS_MODULES = yes? "Would you like to use postcss-modules to isolate component styles?"

  if USE_POSTCSS_MODULES
    run "yarn add postcss-modules"

    if File.read("postcss.config.js").match(/plugins:\s*\[/)
      inject_into_file "postcss.config.js", after: "plugins: [" do
        <<-CODE

  require('postcss-modules')({
      generateScopedName: (name, filename, _css) => {
      const matches = filename.match(/#{ROOT_PATH.gsub('/', '\/')}\\/?(.*)\\/index.css$/);
      // Do not transform CSS files from outside of the components folder
      if (!matches) return name;

      // identifier here is the same identifier we used for Stimulus controller (see above)
      const identifier = matches[1].replace(/\\//g, "--");

      // We also add the `c-` prefix to all components classes
      return `c-${identifier}-${name}`;
    },
    // Do not generate *.css.json files (we don't use them)
    getJSON: () => {}
  }),
        CODE
      end
    else
      inject_into_file "postcss.config.js", after: "plugins: {" do
        <<-CODE

  'postcss-modules': {
      generateScopedName: (name, filename, _css) => {
      const matches = filename.match(/#{ROOT_PATH.gsub('/', '\/')}\\/?(.*)\\/index.css$/);
      // Do not transform CSS files from outside of the components folder
      if (!matches) return name;

      // identifier here is the same identifier we used for Stimulus controller (see above)
      const identifier = matches[1].replace(/\\//g, "--");

      // We also add the `c-` prefix to all components classes
      return `c-${identifier}-${name}`;
    },
    // Do not generate *.css.json files (we don't use them)
    getJSON: () => {}
  },
        CODE
      end
    end

    if !USE_STIMULUS
      inject_into_file "#{ROOT_PATH}/application_view_component.rb", before: "\nend" do
        ERB.new(
    *[
  <<~'TCODE'


  private

  def identifier
    @identifier ||= self.class.name.sub("::Component", "").underscore.split("/").join("--")
  end
  TCODE
  ], trim_mode: "<>").result(binding)
      end
    end

    inject_into_file "#{ROOT_PATH}/application_view_component.rb", before: "\nend" do
      ERB.new(
    *[
  <<~'TCODE'
  def class_for(name, from: identifier)
    "c-\#{from}-\#{name}"
  end
  TCODE
  ], trim_mode: "<>").result(binding)
    end

    say_status :info, "✅ postcss-modules configured"
  end
else
  say "⚠️  See the discussion on how to configure non-Wepback JS/CSS installations: https://github.com/palkan/view_component-contrib/discussions/14"
end

if yes?("Would you like to create a custom generator for your setup? (Recommended)")
  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_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_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_WEBPACK
    inject_into_file "lib/generators/view_component/view_component_generator.rb", after: "class_option :skip_preview, type: :boolean, default: false\n" do
      <<-CODE
  class_option :skip_js, type: :boolean, default: false
  class_option :skip_css, type: :boolean, default: false
      CODE
    end

    inject_into_file "lib/generators/view_component/view_component_generator.rb", before: "\n  private" do
      <<-CODE
  def create_css_file
    return if options[:skip_css] || options[:skip_js]

    template "index.css", File.join("#{ROOT_PATH}", class_path, file_name, "index.css")
  end

  def create_js_file
    return if options[:skip_js]

    template "index.js", File.join("#{ROOT_PATH}", class_path, file_name, "index.js")
  end
      CODE
    end
  end

  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 %>
        <%- 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 %>
        <%- 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.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_WEBPACK
    if USE_STIMULUS
      file "lib/generators/view_component/templates/index.js.tt",
      <<-CODE
import "./index.css"

// Add a Stimulus controller for this component.
// It will automatically registered and its name will be available
// via #component_name in the component class.
//
// import { Controller as BaseController } from "stimulus";
//
// export class Controller extends BaseController {
//   connect() {
//   }
//
//   disconnect() {
//   }
// }
      CODE
    else
      file "lib/generators/view_component/templates/index.js.tt", <<~CODE
      import "./index.css"

      CODE
    end

    if USE_POSTCSS_MODULES
      file "lib/generators/view_component/templates/index.css.tt", <<~CODE
      /* Use component-local class names and add them to HTML via #class_for(name) helper */

      CODE
    else
      file "lib/generators/view_component/templates/index.css.tt", ""
    end
  end

  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_component }

    it "renders" do
      render_inline(component)

      is_expected.to have_css "div"
    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 < ActiveSupport::TestCase
    include ViewComponent::TestHelpers

    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
  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
          Preview:      #{ROOT_PATH}/profile/component_preview.rb
  CODE

  if USE_WEBPACK
    inject_into_file "lib/generators/view_component/USAGE" do
      <<-CODE
          JS:           #{ROOT_PATH}/profile/component.js
          CSS:          #{ROOT_PATH}/profile/component.css
      CODE
    end
  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.

© 2023 GoRails, LLC