View Component Contrib
Installs ViewComponent and adds ViewComponent::Contrib extensions and configuration settings
Used 984 times
V
Vladimir Dementyev
Usage
ViewComponent::Contrib contains extensions, patches and guides for working with ViewComponent.
This template:
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!"