diff --git a/gems/bess/lib/generators/react/component_generator.rb b/gems/bess/lib/generators/react/component_generator.rb new file mode 100644 index 00000000..fdbf7e0e --- /dev/null +++ b/gems/bess/lib/generators/react/component_generator.rb @@ -0,0 +1,251 @@ +# frozen_string_literal: true + +# License: AGPL-3.0-or-later WITH WTO-AP-3.0-or-later +# Full license explanation at https://github.com/houdiniproject/houdini/blob/master/LICENSE +# from: https://github.com/reactjs/react-rails/blob/master/lib/generators/react/component_generator.rb +module React + module Generators + class ComponentGenerator < ::Rails::Generators::NamedBase + source_root File.expand_path '../../templates', __FILE__ + desc <<-DESC.strip_heredoc + Description: + Scaffold a React component into `components/` of your Webpacker 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 :ts, + type: :boolean, + default: true, + desc: 'Output tsx class 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' => ->(type) { + 'PropTypes.instanceOf(%s)' % type.to_s.camelize + }, + + 'oneOf' => ->(*options) { + enums = options.map{ |k| "'#{k.to_s}'" }.join(',') + 'PropTypes.oneOf([%s])' % enums + }, + + 'oneOfType' => ->(*options) { + types = options.map{ |k| "#{lookup(k.to_s, k.to_s)}" }.join(',') + 'PropTypes.oneOfType([%s])' % types + } + } + + TYPESCRIPT_TYPES = { + 'node' => 'React.ReactNode', + 'bool' => 'boolean', + 'boolean' => 'boolean', + 'string' => 'string', + 'number' => 'number', + 'object' => 'object', + 'array' => 'Array', + 'shape' => 'object', + 'element' => 'object', + 'func' => 'object', + 'function' => 'object', + 'any' => 'any', + + 'instanceOf' => ->(type) { + type.to_s.camelize + }, + + 'oneOf' => ->(*opts) { + opts.map{ |k| "'#{k.to_s}'" }.join(" | ") + }, + + 'oneOfType' => ->(*opts) { + opts.map{ |k| "#{ts_lookup(k.to_s, k.to_s)}" }.join(" | ") + } + } + + def create_component_file + template_extension = if options[:coffee] + 'js.jsx.coffee' + elsif options[:ts] + 'js.jsx.tsx' + elsif options[:es6] || webpacker? + 'es6.jsx' + else + 'js.jsx' + end + + # Prefer webpacker to sprockets: + if webpacker? + 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 + Webpacker.respond_to?(:config) ? Webpacker.config : Webpacker::Configuration + end + + def component_name + file_name.camelize + end + + def file_header + if webpacker? + return %|import * as React from "react"\n| if options[:ts] + %|import React from "react"\nimport PropTypes from "prop-types"\n| + else + '' + end + end + + def file_footer + if webpacker? + %|export default #{component_name}| + else + '' + end + end + + def webpacker? + defined?(Webpacker) + end + + def parse_attributes! + self.attributes = (attributes || []).map do |attr| + name = '' + type = '' + args = '' + args_regex = /(?{.*})/ + + 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 = '') + return 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? + if type =~ /^[[:upper:]]/ + ts_type = TYPESCRIPT_TYPES['instanceOf'] + else + ts_type = TYPESCRIPT_TYPES['node'] + end + end + + args = args.to_s.gsub(/[{}]/, '').split(',') + + if ts_type.respond_to? :call + if args.blank? + return ts_type.call(type) + end + + 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? + if type =~ /^[[:upper:]]/ + react_prop_type = REACT_PROP_TYPES['instanceOf'] + else + react_prop_type = 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 + end + end + end \ No newline at end of file diff --git a/gems/bess/lib/generators/templates/component.js.jsx.tsx b/gems/bess/lib/generators/templates/component.js.jsx.tsx new file mode 100644 index 00000000..afb4a998 --- /dev/null +++ b/gems/bess/lib/generators/templates/component.js.jsx.tsx @@ -0,0 +1,37 @@ +<% # License: LGPL-3.0-or-later +# Full license explanation at https://github.com/houdiniproject/houdini/blob/master/LICENSE +# from: https://github.com/reactjs/react-rails/blob/master/lib/generators/templates/component.js.jsx.tsx +%> +// License: LGPL-3.0-or-later +<%= file_header %> +<% unions = attributes.select{ |a| a[:union] } -%> +<% if unions.size > 0 -%> +<% unions.each do |e| -%> +type <%= e[:name].titleize %> = <%= e[:type]%> +<% end -%> +<% end -%> + +interface I<%= component_name %>Props { +<% if attributes.size > 0 -%> +<% attributes.each do | attribute | -%> +<% if attribute[:union] -%> + <%= attribute[:name].camelize(:lower) %>: <%= attribute[:name].titleize %>; +<% else -%> + <%= attribute[:name].camelize(:lower) %>: <%= attribute[:type] %>; +<% end -%> +<% end -%> +<% end -%> +} + + +function <%= component_name %>(props:I<%= component_name %>Props) { + return ( + + <% attributes.each do |attribute| -%> + <%= attribute[:name].titleize %>: {props.<%= attribute[:name].camelize(:lower) %>} + <% end -%> + + ); +} + +<%= file_footer %> \ No newline at end of file