reactjs/react-rails

View on GitHub
lib/generators/react/component_generator.rb

Summary

Maintainability
A
1 hr
Test Coverage
# frozen_string_literal: true

module React
  module Generators
    class ComponentGenerator < ::Rails::Generators::NamedBase
      source_root File.expand_path "../templates", __dir__
      desc <<-DESC.strip_heredoc
      Description:
          Scaffold a React component into `components/` of your Shakapacker source or asset pipeline.
          The generated component will include a basic render function and a PropTypes
          hash to help with development.

      Available field types:

          Basic prop types do not take any additional arguments. If you do not specify
          a prop type, the generic node will be used. The basic types available are:

          any
          array
          bool
          element
          func
          number
          object
          node
          shape
          string

          Special PropTypes take additional arguments in {}, and must be enclosed in
          single quotes to keep bash from expanding the arguments in {}.

          instanceOf
          takes an optional class name in the form of {className}

          oneOf
          behaves like an enum, and takes an optional list of strings that will
          be allowed in the form of 'name:oneOf{one,two,three}'.

          oneOfType.
          oneOfType takes an optional list of react and custom types in the form of
          'model:oneOfType{string,number,OtherType}'

      Examples:
          rails g react:component person name
          rails g react:component restaurant name:string rating:number owner:instanceOf{Person}
          rails g react:component food 'kind:oneOf{meat,cheese,vegetable}'
          rails g react:component events 'location:oneOfType{string,Restaurant}'
      DESC

      argument :attributes,
               type: :array,
               default: [],
               banner: "field[:type] field[:type] ..."

      class_option :es6,
                   type: :boolean,
                   default: false,
                   desc: "Output es6 class based component"

      class_option :ts,
                   type: :boolean,
                   default: false,
                   desc: "Output tsx class based component"

      class_option :coffee,
                   type: :boolean,
                   default: false,
                   desc: "Output coffeescript based component"

      REACT_PROP_TYPES = {
        "node" => "PropTypes.node",
        "bool" => "PropTypes.bool",
        "boolean" => "PropTypes.bool",
        "string" => "PropTypes.string",
        "number" => "PropTypes.number",
        "object" => "PropTypes.object",
        "array" => "PropTypes.array",
        "shape" => "PropTypes.shape({})",
        "element" => "PropTypes.element",
        "func" => "PropTypes.func",
        "function" => "PropTypes.func",
        "any" => "PropTypes.any",

        "instanceOf" => lambda { |type|
          "PropTypes.instanceOf(#{type.to_s.camelize})"
        },

        "oneOf" => lambda { |*options|
          enums = options.map { |k| "'#{k}'" }.join(",")
          "PropTypes.oneOf([#{enums}])"
        },

        "oneOfType" => lambda { |*options|
          types = options.map { |k| lookup(k.to_s, k.to_s).to_s }.join(",")
          "PropTypes.oneOfType([#{types}])"
        }
      }.freeze

      TYPESCRIPT_TYPES = {
        "node" => "React.ReactNode",
        "bool" => "boolean",
        "boolean" => "boolean",
        "string" => "string",
        "number" => "number",
        "object" => "object",
        "array" => "Array<any>",
        "shape" => "object",
        "element" => "object",
        "func" => "object",
        "function" => "object",
        "any" => "any",

        "instanceOf" => lambda { |type|
          type.to_s.camelize
        },

        "oneOf" => lambda { |*opts|
          opts.map { |k| "'#{k}'" }.join(" | ")
        },

        "oneOfType" => lambda { |*opts|
          opts.map { |k| ts_lookup(k.to_s, k.to_s).to_s }.join(" | ")
        }
      }.freeze

      def create_component_file
        template_extension = detect_template_extension
        # Prefer Shakapacker to Sprockets:
        if shakapacker?
          new_file_name = file_name.camelize
          extension = if options[:coffee]
                        "coffee"
                      elsif options[:ts]
                        "tsx"
                      else
                        "js"
                      end
          target_dir = webpack_configuration.source_path
                                            .join("components")
                                            .relative_path_from(::Rails.root)
                                            .to_s
        else
          new_file_name = file_name
          extension = template_extension
          target_dir = "app/assets/javascripts/components"
        end

        file_path = File.join(target_dir, class_path, "#{new_file_name}.#{extension}")
        template("component.#{template_extension}", file_path)
      end

      private

      def webpack_configuration
        Shakapacker.respond_to?(:config) ? Shakapacker.config : Shakapacker::Configuration
      end

      def component_name
        file_name.camelize
      end

      def file_header
        if shakapacker?
          return %(import * as React from "react"\n) if options[:ts]

          <<~JS
            import React from "react"
            import PropTypes from "prop-types"
          JS
        else
          ""
        end
      end

      def file_footer
        if shakapacker?
          %(export default #{component_name})
        else
          ""
        end
      end

      def shakapacker?
        defined?(Shakapacker)
      end

      def parse_attributes!
        self.attributes = (attributes || []).map do |attr|
          args = ""
          args_regex = /(?<args>{.*})/

          name, type = attr.split(":")

          if (matchdata = args_regex.match(type))
            args = matchdata[:args]
            type = type.gsub(args_regex, "")
          end

          if options[:ts]
            { name: name, type: ts_lookup(name, type, args), union: union?(args) }
          else
            { name: name, type: lookup(type, args) }
          end
        end
      end

      def union?(args = "")
        args.to_s.gsub(/[{}]/, "").split(",").count > 1
      end

      def self.ts_lookup(_name, type = "node", args = "")
        ts_type = TYPESCRIPT_TYPES[type]
        if ts_type.blank?
          ts_type = if /^[[:upper:]]/.match?(type)
                      TYPESCRIPT_TYPES["instanceOf"]
                    else
                      TYPESCRIPT_TYPES["node"]
                    end
        end

        args = args.to_s.gsub(/[{}]/, "").split(",")

        if ts_type.respond_to? :call
          return ts_type.call(type) if args.blank?

          ts_type = ts_type.call(*args)
        end

        ts_type
      end

      def ts_lookup(name, type = "node", args = "")
        self.class.ts_lookup(name, type, args)
      end

      def self.lookup(type = "node", options = "")
        react_prop_type = REACT_PROP_TYPES[type]
        if react_prop_type.blank?
          react_prop_type = if /^[[:upper:]]/.match?(type)
                              REACT_PROP_TYPES["instanceOf"]
                            else
                              REACT_PROP_TYPES["node"]
                            end
        end

        options = options.to_s.gsub(/[{}]/, "").split(",")

        react_prop_type = react_prop_type.call(*options) if react_prop_type.respond_to? :call
        react_prop_type
      end

      def lookup(type = "node", options = "")
        self.class.lookup(type, options)
      end

      def detect_template_extension
        if options[:coffee]
          "js.jsx.coffee"
        elsif options[:ts] && es6_enabled?
          "es6.tsx"
        elsif options[:ts]
          "js.jsx.tsx"
        elsif es6_enabled?
          "es6.jsx"
        else
          "js.jsx"
        end
      end

      def es6_enabled?
        options[:es6] || shakapacker?
      end
    end
  end
end