From 9f2a3f73c8a1bd94203c57216c5b061604559837 Mon Sep 17 00:00:00 2001 From: Rafe Rosen Date: Sun, 13 Jun 2021 02:29:00 -0400 Subject: [PATCH 1/3] Add scaffold generator --- lib/generators/cable_ready/scaffold/USAGE | 45 ++++++++++ .../scaffold/scaffold_generator.rb | 90 +++++++++++++++++++ .../templates/cable_car_controller.js | 18 ++++ .../scaffold/templates/controller.rb.tt | 85 ++++++++++++++++++ .../scaffold/templates/erb/_form.html.erb.tt | 38 ++++++++ .../templates/erb/_resource.html.erb.tt | 14 +++ .../scaffold/templates/erb/index.html.erb.tt | 11 +++ .../scaffold/templates/erb/show.html.erb.tt | 5 ++ 8 files changed, 306 insertions(+) create mode 100644 lib/generators/cable_ready/scaffold/USAGE create mode 100644 lib/generators/cable_ready/scaffold/scaffold_generator.rb create mode 100644 lib/generators/cable_ready/scaffold/templates/cable_car_controller.js create mode 100644 lib/generators/cable_ready/scaffold/templates/controller.rb.tt create mode 100644 lib/generators/cable_ready/scaffold/templates/erb/_form.html.erb.tt create mode 100644 lib/generators/cable_ready/scaffold/templates/erb/_resource.html.erb.tt create mode 100644 lib/generators/cable_ready/scaffold/templates/erb/index.html.erb.tt create mode 100644 lib/generators/cable_ready/scaffold/templates/erb/show.html.erb.tt 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..ffed2760 --- /dev/null +++ b/lib/generators/cable_ready/scaffold/scaffold_generator.rb @@ -0,0 +1,90 @@ +# 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" + + 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") } + matches = lines.select { |line| line =~ /\A(require|import)/ } + lines.insert lines.index(matches.last).to_i + 1, "import Rails from '@rails/ujs'\nRails.start()\n" + File.open(filepath, "w") { |f| f.write lines.join } + + say "Adding @rails/ujs via yarn" + system "bin/yarn add @rails/ujs" + end + + template "cable_car_controller.js", File.join(main_folder, "controllers", "cable_car_controller.js") + 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 +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..c32575a8 --- /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}, id: dom_id(<%= singular_table_name %>)) do |form| %> + <%% if <%= singular_table_name %>.errors.any? %> +
+

<%%= pluralize(<%= singular_table_name %>.errors.count, "error") %> prohibited this <%= singular_table_name %> from being saved:

+ + +
+ <%% end %> + +<% attributes.each do |attribute| -%> +
+<% if attribute.password_digest? -%> + <%%= form.label :password %>
+ <%%= form.password_field :password %> +
+ +
+ <%%= form.label :password_confirmation %>
+ <%%= form.password_field :password_confirmation %> +<% elsif attribute.attachments? -%> + <%%= form.label :<%= attribute.column_name %> %>
+ <%%= form.<%= attribute.field_type %> :<%= attribute.column_name %>, multiple: true %> +<% else -%> + <%%= form.label :<%= attribute.column_name %> %>
+ <%%= form.<%= attribute.field_type %> :<%= attribute.column_name %> %> +<% end -%> +
+ +<% end -%> +
+ <%%= form.submit %> + <%%= link_to "Cancel", <%= plural_table_name %>_path, data: {remote: true, type: "json"} %> +
+<%% end %> diff --git a/lib/generators/cable_ready/scaffold/templates/erb/_resource.html.erb.tt b/lib/generators/cable_ready/scaffold/templates/erb/_resource.html.erb.tt new file mode 100644 index 00000000..1db369ed --- /dev/null +++ b/lib/generators/cable_ready/scaffold/templates/erb/_resource.html.erb.tt @@ -0,0 +1,14 @@ +
+
+ <% attributes.reject(&:password_digest?).each do |attribute| -%> +
<%= attribute.human_name %>
+
<%%= <%= singular_table_name %>.<%= attribute.column_name %> %>
+ <% end -%> +
+ +
+ <%%= link_to '[show]', <%= model_resource_name %> %> + <%%= link_to '[edit]', edit_<%= singular_route_name %>_path(<%= singular_table_name %>), data: {remote: true}%> + <%%= link_to '[destroy]', <%= model_resource_name %>, method: :delete, data: { confirm: 'Are you sure?', remote: true } %> +
+
diff --git a/lib/generators/cable_ready/scaffold/templates/erb/index.html.erb.tt b/lib/generators/cable_ready/scaffold/templates/erb/index.html.erb.tt new file mode 100644 index 00000000..c594df6a --- /dev/null +++ b/lib/generators/cable_ready/scaffold/templates/erb/index.html.erb.tt @@ -0,0 +1,11 @@ +
+

<%= plural_table_name.titleize %>

+ +
+ <%%= render @<%= plural_table_name %> %> +
+ +
+ + <%%= link_to 'New <%= singular_table_name.titleize %>', new_<%= singular_route_name %>_path, data: {remote: true} %> +
diff --git a/lib/generators/cable_ready/scaffold/templates/erb/show.html.erb.tt b/lib/generators/cable_ready/scaffold/templates/erb/show.html.erb.tt new file mode 100644 index 00000000..debc0750 --- /dev/null +++ b/lib/generators/cable_ready/scaffold/templates/erb/show.html.erb.tt @@ -0,0 +1,5 @@ +
+ <%%= render @<%= singular_table_name %> %> + + <%%= link_to 'Back', <%= index_helper %>_path %> +
From 49a1b6389736d85eb0f78845fedaf54ec6a8e6d7 Mon Sep 17 00:00:00 2001 From: Rafe Rosen Date: Sun, 13 Jun 2021 18:22:14 -0400 Subject: [PATCH 2/3] Add system test generator --- .../scaffold/scaffold_generator.rb | 52 +++++++++++++-- .../scaffold/templates/system_test.rb.tt | 64 +++++++++++++++++++ 2 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 lib/generators/cable_ready/scaffold/templates/system_test.rb.tt diff --git a/lib/generators/cable_ready/scaffold/scaffold_generator.rb b/lib/generators/cable_ready/scaffold/scaffold_generator.rb index ffed2760..28deb314 100644 --- a/lib/generators/cable_ready/scaffold/scaffold_generator.rb +++ b/lib/generators/cable_ready/scaffold/scaffold_generator.rb @@ -12,15 +12,18 @@ class CableReady::ScaffoldGenerator < Rails::Generators::ResourceGenerator 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") + 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 + # TODO: ERB only for now, add alternate template engine support def copy_view_files available_views.each do |view| target_view = if view == "_resource" @@ -44,7 +47,7 @@ def install_scripts end else main_folder = File.join("app", "assets", "javascripts") - # TODO automate? + # TODO: automate? say "Please be sure Stimulus is installed. See https://github.com/hotwired/stimulus-rails" end @@ -59,17 +62,20 @@ def install_scripts lines = File.open(filepath, "r") { |f| f.readlines } unless lines.find { |line| line.start_with?("import Rails") } - matches = lines.select { |line| line =~ /\A(require|import)/ } - lines.insert lines.index(matches.last).to_i + 1, "import Rails from '@rails/ujs'\nRails.start()\n" - File.open(filepath, "w") { |f| f.write lines.join } - 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 @@ -87,4 +93,36 @@ 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/system_test.rb.tt b/lib/generators/cable_ready/scaffold/templates/system_test.rb.tt new file mode 100644 index 00000000..26d4d1c5 --- /dev/null +++ b/lib/generators/cable_ready/scaffold/templates/system_test.rb.tt @@ -0,0 +1,64 @@ +require "application_system_test_case" + +<% module_namespacing do -%> +class <%= class_name.pluralize %>Test < ApplicationSystemTestCase + include ActionView::RecordIdentifier + + setup do + @<%= singular_table_name %> = <%= fixture_name %>(:one) + end + + test "visiting the index" do + visit <%= plural_table_name %>_url + assert_selector "h1", text: "<%= class_name.pluralize.titleize %>" + end + + test "creating a <%= human_name %>" do + visit <%= plural_table_name %>_url + click_on "New <%= class_name.titleize %>" + + <%- attributes_hash.each do |attr, value| -%> + <%- if boolean?(attr) -%> + check "<%= attr.humanize %>" if <%= value %> + <%- else -%> + fill_in "<%= attr.humanize %>", with: <%= value %> + <%- end -%> + <%- end -%> + click_on "Create <%= human_name %>" + + <%- attributes_hash.each do |_attr, value| -%> + assert_text <%= value %> + <%- end %> + end + + test "updating a <%= human_name %>" do + visit <%= plural_table_name %>_url + click_on "[edit]", match: :first + + <%- attributes_hash.each do |attr, value| -%> + <%- if boolean?(attr) -%> + check "<%= attr.humanize %>" if <%= value %> + <%- else -%> + fill_in "<%= attr.humanize %>", with: <%= value %> + <%- end -%> + <%- end -%> + click_on "Update <%= human_name %>" + + <%- attributes_hash.each do |_attr, value| -%> + assert_text <%= value %> + <%- end %> + end + + test "destroying a <%= human_name %>" do + visit <%= plural_table_name %>_url + record_selector = "##{dom_id(<%= fixture_name %>(:one))}" + page.accept_confirm do + within record_selector do + click_on "[destroy]" + end + end + + refute_selector record_selector + end +end +<% end -%> From 78fd747e45b4844a67380c2f9f589ac474a68a51 Mon Sep 17 00:00:00 2001 From: Rafe Rosen Date: Sun, 13 Jun 2021 20:15:50 -0400 Subject: [PATCH 3/3] nicer spacing with inline styles --- .../scaffold/templates/erb/_form.html.erb.tt | 12 ++++++------ .../scaffold/templates/erb/_resource.html.erb.tt | 8 ++++---- .../scaffold/templates/erb/index.html.erb.tt | 4 +--- 3 files changed, 11 insertions(+), 13 deletions(-) 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 index c32575a8..a226b5f3 100644 --- a/lib/generators/cable_ready/scaffold/templates/erb/_form.html.erb.tt +++ b/lib/generators/cable_ready/scaffold/templates/erb/_form.html.erb.tt @@ -1,6 +1,6 @@ -<%%= form_with(model: <%= model_resource_name %>, data: {remote: true}, id: dom_id(<%= singular_table_name %>)) do |form| %> +<%%= 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? %> -
+

<%%= pluralize(<%= singular_table_name %>.errors.count, "error") %> prohibited this <%= singular_table_name %> from being saved:

    @@ -12,13 +12,13 @@ <%% end %> <% attributes.each do |attribute| -%> -
    +
    <% if attribute.password_digest? -%> <%%= form.label :password %>
    <%%= form.password_field :password %>
    -
    +
    <%%= form.label :password_confirmation %>
    <%%= form.password_field :password_confirmation %> <% elsif attribute.attachments? -%> @@ -31,8 +31,8 @@
    <% end -%> -
    +
    <%%= form.submit %> - <%%= link_to "Cancel", <%= plural_table_name %>_path, data: {remote: true, type: "json"} %> + <%%= link_to "Cancel", <%= plural_table_name %>_path, data: {remote: true, type: "json"}, style: "margin-left: 8px;" %>
    <%% end %> diff --git a/lib/generators/cable_ready/scaffold/templates/erb/_resource.html.erb.tt b/lib/generators/cable_ready/scaffold/templates/erb/_resource.html.erb.tt index 1db369ed..c1adc51e 100644 --- a/lib/generators/cable_ready/scaffold/templates/erb/_resource.html.erb.tt +++ b/lib/generators/cable_ready/scaffold/templates/erb/_resource.html.erb.tt @@ -1,4 +1,4 @@ -
    +
    <% attributes.reject(&:password_digest?).each do |attribute| -%>
    <%= attribute.human_name %>
    @@ -6,9 +6,9 @@ <% end -%>
    -
    +
    <%%= link_to '[show]', <%= model_resource_name %> %> - <%%= link_to '[edit]', edit_<%= singular_route_name %>_path(<%= singular_table_name %>), data: {remote: true}%> - <%%= link_to '[destroy]', <%= model_resource_name %>, method: :delete, data: { confirm: 'Are you sure?', remote: true } %> + <%%= link_to '[edit]', edit_<%= singular_route_name %>_path(<%= singular_table_name %>), data: {remote: true, type: "json"}%> + <%%= link_to '[destroy]', <%= model_resource_name %>, method: :delete, data: { confirm: 'Are you sure?', remote: true, type: "json" } %>
    diff --git a/lib/generators/cable_ready/scaffold/templates/erb/index.html.erb.tt b/lib/generators/cable_ready/scaffold/templates/erb/index.html.erb.tt index c594df6a..18f4395d 100644 --- a/lib/generators/cable_ready/scaffold/templates/erb/index.html.erb.tt +++ b/lib/generators/cable_ready/scaffold/templates/erb/index.html.erb.tt @@ -5,7 +5,5 @@ <%%= render @<%= plural_table_name %> %>
    -
    - - <%%= link_to 'New <%= singular_table_name.titleize %>', new_<%= singular_route_name %>_path, data: {remote: true} %> + <%%= link_to 'New <%= singular_table_name.titleize %>', new_<%= singular_route_name %>_path, data: {remote: true, type: "json"} %>