diff --git a/lib/generators/cable_ready/scaffold/USAGE b/lib/generators/cable_ready/scaffold/USAGE new file mode 100644 index 00000000..ea0b26a8 --- /dev/null +++ b/lib/generators/cable_ready/scaffold/USAGE @@ -0,0 +1,45 @@ +Description: + Creates a CableReady-driven resource scaffold with in-page editing + via UJS remote links/forms and `render operations: cable_car`. + + Scaffolds the entire resource, from model and migration to controller and + views, along with a full test suite. It will also ensure all javascript + dependencies for in-page editing are set up (Stimulus, RailsUJS, and a + custom Stimulus controller). + + Pass the name of the model (in singular form), either CamelCased or + under_scored, as the first argument, and an optional list of attribute + pairs. + + Attributes are field arguments specifying the model's attributes. You can + optionally pass the type and an index to each field. For instance: + 'title body:text tracking_id:integer:uniq' will generate a title field of + string type, a body with text type and a tracking_id as an integer with an + unique index. "index" could also be given instead of "uniq" if one desires + a non unique index. + + As a special case, specifying 'password:digest' will generate a + password_digest field of string type, and configure your generated model, + controller, views, and test suite for use with Active Model + has_secure_password (assuming they are using Rails defaults). + + Timestamps are added by default, so you don't have to specify them by hand + as 'created_at:datetime updated_at:datetime'. + + You don't have to think up every attribute up front, but it helps to + sketch out a few so you can start working with the resource immediately. + + For example, 'scaffold post title body:text published:boolean' gives + you a model with those three attributes, a controller that handles + the create/show/update/destroy, forms to create and edit your posts, and + an index that lists them all, as well as a resources :posts declaration + in config/routes.rb. + + If you want to remove all the generated files, run + 'bin/rails destroy cable_ready:scaffold ModelName'. + +Examples: + `bin/rails generate cable_ready:scaffold post` + `bin/rails generate cable_ready:scaffold post title:string body:text published:boolean` + `bin/rails generate cable_ready:scaffold purchase amount:decimal tracking_id:integer:uniq` + `bin/rails generate cable_ready:scaffold user email:uniq password:digest` diff --git a/lib/generators/cable_ready/scaffold/scaffold_generator.rb b/lib/generators/cable_ready/scaffold/scaffold_generator.rb new file mode 100644 index 00000000..28deb314 --- /dev/null +++ b/lib/generators/cable_ready/scaffold/scaffold_generator.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "rails/generators/rails/resource/resource_generator" +require "rake" + +class CableReady::ScaffoldGenerator < Rails::Generators::ResourceGenerator + include Rails::Generators::ResourceHelpers + + source_root File.expand_path("templates", __dir__) + remove_hook_for :resource_controller + remove_class_option :actions + + check_class_collision suffix: "Controller" + + class_option :skip_system_tests, type: :boolean, default: false, desc: "Skip system test files" + + def create_controller_files + template "controller.rb", + File.join("app/controllers", controller_class_path, "#{controller_file_name}_controller.rb") + end + + def create_view_root + empty_directory File.join("app/views", controller_file_path) + end + + # TODO: ERB only for now, add alternate template engine support + def copy_view_files + available_views.each do |view| + target_view = if view == "_resource" + "_#{controller_file_path.singularize}" + else + view + end + + source_filename = File.join("erb", (view + ".html.erb")) + target_filename = target_view + ".html.erb" + template source_filename, File.join("app/views", controller_file_path, target_filename) + end + end + + def install_scripts + if defined?(Webpacker) + main_folder = Webpacker.config.source_path.to_s.gsub("#{Rails.root}/", "") + unless File.exist? File.join("app", "javascript", "controllers") + say "Adding Stimulus" + system "bin/rake webpacker:install:stimulus" + end + else + main_folder = File.join("app", "assets", "javascripts") + # TODO: automate? + say "Please be sure Stimulus is installed. See https://github.com/hotwired/stimulus-rails" + end + + filepath = [ + "#{main_folder}/packs/application.js", + "#{main_folder}/packs/application.ts" + ] + .select { |path| File.exist?(path) } + .map { |path| Rails.root.join(path) } + .first + + lines = File.open(filepath, "r") { |f| f.readlines } + + unless lines.find { |line| line.start_with?("import Rails") } + say "Adding @rails/ujs via yarn" + system "bin/yarn add @rails/ujs" + append_file filepath, "\nimport Rails from '@rails/ujs'\nRails.start()\n" + end + + template "cable_car_controller.js", File.join(main_folder, "controllers", "cable_car_controller.js") + end + + def create_test_files + unless options[:skip_system_tests] + template "system_test.rb", File.join("test/system", class_path, "#{file_name.pluralize}_test.rb") + end + end + + private + + def available_views + %w[index show _form _resource] + end + + def permitted_params + attachments, others = attributes_names.partition { |name| attachments?(name) } + params = others.map { |name| ":#{name}" } + params += attachments.map { |name| "#{name}: []" } + params.join(", ") + end + + def attachments?(name) + attribute = attributes.find { |attr| attr.name == name } + attribute&.attachments? + end + + # For system tests + def attributes_hash + return {} if attributes_names.empty? + + attributes_names.map do |name| + if %w[password password_confirmation].include?(name) && attributes.any?(&:password_digest?) + [name.to_s, "'secret'"] + elsif !virtual?(name) + [name.to_s, "@#{singular_table_name}.#{name}"] + end + end.compact.sort.to_h + end + + def boolean?(name) + attribute = attributes.find { |attr| attr.name == name } + attribute&.type == :boolean + end + + def virtual?(name) + attribute = attributes.find { |attr| attr.name == name } + attribute&.virtual? + end + + def fixture_name + @fixture_name ||= + if mountable_engine? + (namespace_dirs + [table_name]).join("_") + else + table_name + end + end +end diff --git a/lib/generators/cable_ready/scaffold/templates/cable_car_controller.js b/lib/generators/cable_ready/scaffold/templates/cable_car_controller.js new file mode 100644 index 00000000..847ac05a --- /dev/null +++ b/lib/generators/cable_ready/scaffold/templates/cable_car_controller.js @@ -0,0 +1,18 @@ +import { Controller } from 'stimulus' +import CableReady from 'cable_ready' + +export default class extends Controller { + connect() { + console.log("Connect!") + this.boundPerform = this.perform.bind(this) + this.element.addEventListener("ajax:success", this.boundPerform) + } + + perform(event) { + CableReady.perform(event.detail[0]) + } + + disconnect() { + this.element.removeEventListener("ajax:success", this.boundPerform) + } +} diff --git a/lib/generators/cable_ready/scaffold/templates/controller.rb.tt b/lib/generators/cable_ready/scaffold/templates/controller.rb.tt new file mode 100644 index 00000000..aac0c5d0 --- /dev/null +++ b/lib/generators/cable_ready/scaffold/templates/controller.rb.tt @@ -0,0 +1,85 @@ +<% if namespaced? -%> +require_dependency "<%= namespaced_path %>/application_controller" + +<% end -%> +<% module_namespacing do -%> +class <%= controller_class_name %>Controller < ApplicationController + include CableReady::Broadcaster + before_action :set_<%= singular_table_name %>, only: [:show, :edit, :update, :destroy] + + # GET <%= route_url %> + def index + @<%= plural_table_name %> = <%= orm_class.all(class_name) %> + respond_to do |format| + format.html { render } + format.json { render operations: cable_car.inner_html(@<%= plural_table_name %>, html: self.class.render(@<%= plural_table_name %>)) } + end + end + + # GET <%= route_url %>/1 + def show + end + + # GET <%= route_url %>/new + def new + @<%= singular_table_name %> = <%= orm_class.build(class_name) %> + render operations: cable_car.append(selector: "#<%= plural_table_name %>", html: render_form(@<%= singular_table_name %>)) + end + + # GET <%= route_url %>/1/edit + def edit + render operations: cable_car.outer_html(selector: @<%= singular_table_name %>, html: render_form(@<%= singular_table_name %>)) + end + + # POST <%= route_url %> + def create + @<%= singular_table_name %> = <%= orm_class.build(class_name, "#{singular_table_name}_params") %> + + if @<%= orm_instance.save %> + render operations: cable_car + .append(selector: "#<%= plural_table_name %>", html: render_<%= singular_table_name %>(@<%= singular_table_name %>)) + .remove(selector: "#new_<%= singular_table_name %>") + else + render operations: cable_car.outer_html(selector: @<%= singular_table_name %>, html: render_form(@<%= singular_table_name %>)) + end + end + + # PATCH/PUT <%= route_url %>/1 + def update + if @<%= orm_instance.update("#{singular_table_name}_params") %> + render operations: cable_car.outer_html(selector: @<%= singular_table_name %>, html: render_<%= singular_table_name %>(@<%= singular_table_name %>)) + else + render operations: cable_car.outer_html(selector: @<%= singular_table_name %>, html: render_form(@<%= singular_table_name %>)) + end + end + + # DELETE <%= route_url %>/1 + def destroy + @<%= orm_instance.destroy %> + render operations: cable_car.remove(selector: @<%= singular_table_name %>) + end + + private + # Use callbacks to share common setup or constraints between actions. + def set_<%= singular_table_name %> + @<%= singular_table_name %> = <%= orm_class.find(class_name, "params[:id]") %> + end + + # Only allow a list of trusted parameters through. + def <%= "#{singular_table_name}_params" %> + <%- if attributes_names.empty? -%> + params.fetch(:<%= singular_table_name %>, {}) + <%- else -%> + params.require(:<%= singular_table_name %>).permit(<%= permitted_params %>) + <%- end -%> + end + + def render_<%= singular_table_name %> <%= singular_table_name %> + self.class.render(<%= singular_table_name %>) + end + + def render_form <%= singular_table_name %> + self.class.render(partial: "form", locals: {<%= singular_table_name %>: <%= singular_table_name %>}) + end +end +<% end -%> diff --git a/lib/generators/cable_ready/scaffold/templates/erb/_form.html.erb.tt b/lib/generators/cable_ready/scaffold/templates/erb/_form.html.erb.tt new file mode 100644 index 00000000..a226b5f3 --- /dev/null +++ b/lib/generators/cable_ready/scaffold/templates/erb/_form.html.erb.tt @@ -0,0 +1,38 @@ +<%%= form_with(model: <%= model_resource_name %>, data: {remote: true, type: "json"}, id: dom_id(<%= singular_table_name %>), html: {style: "margin-bottom: 36px;"}) do |form| %> + <%% if <%= singular_table_name %>.errors.any? %> +