Hot Glue Example #6 – Magic Buttons

Example 6: Magic Buttons

Magic buttons are cheating overloaded controllers, which is an antipattern. Instead of adding another ‘action’ on our controller, we want to perform some non-standard update operation. But the non-standard thing happens along with the update operation itself. From the user’s perspective, they click a button from the LIST page. The form submission of this operation will share the update action on the Controller.

For many apps, updating the actual data on the record is probably a less common operation than the meat & potatoes of your app: activating, releasing, canceling, accepting, rejecting, etc.

Magic buttons let you simply add buttons on your list view that will submit to your update action on the controller. From there, the update action will pick up the presence of a special (non-field) flag on the form submission. A little Ruby magic is used to make sure that these special flags aren’t part of the update call on the object, but they will trigger operations on the object with the same name plus a bang (!). For example, if your magic button is activate, in the UI it will show up as “Activate,” and that will call a method on your model object called activate!

The controller will detect the flag activate in the params, even though there is no such field on the object called activate. It will then call the activate! method on the object and automatically suppress activate from being part of the ActiveRecord update call (so as not to try to save a field by the same name).

To be clear, your magic buttons must not be the same name as your field names. Sometimes in designing a schema I will use a name similar to the action name (“activate” is the magic button name and “activated_at” is the field name that indicates when the person got activated), they are like actions you can perform on the objects and should not clash with the names of fields on your models. In the example below, I use accept and reject as the magic buttons with accepted_at and rejected_at fields attached to the associated models.

You shouldn’t abuse this to put too much business logic in the controllers. You should do this only lightly and only in the context of “doing something update-like” on the object. If you are “doing something update-like,” it’s OK to use magic buttons in this way.

However, if you find yourself writing a lot of business logic in the controller, instead you should abstract that logic out. As explained in Example 1, you probably want another controller that itself will process either a create or an update action. (Never make up different action names beyond the non-standard ones.)

From there, you can use mixins and inheritance (or other patterns) between the controllers to share logic.

You should also not take this as an invitation to open the door to putting too much business logic on the models, either. (That’s a different antipattern.) Those sneaky ! methods on your model object probably shouldn’t be very large. If they are, consider writing business objects outside of your MVC layer and using your controller only to orchestrate business objects & database objects. Anything beyond very simple database-like operations on models should get abstracted out into business operation objects which are POROs (“plain old Ruby objects”). For that, you will want to replace Hot Glue’s default code with your own business operation code, passing in any parameters & objects that the business operation object needs to do its job.

The magic button construction — which admittedly is probably borderline antipattern itself— is designed for starter apps and lightweight starter code. Be careful to avoid the common pitfalls of Rails explained above (too much business logic in the model or too much business logic in the controller).

Balancing the need for speed and lightweight solutions with good application design will get you very far here and Hot Glue is designed to be your training wheels only. The magic button implementation does correctly encourage you to move your business logic out of the controller— but stuffing it over into the models using a simple bang method is a quick and convenient hack that probably isn’t necessarily the right solution for the long term of your app.

The Petition Approvable System

Start by building an app called PetitionApprovalableSystem

Remember to use the --database=postgresql file to speed up your setup.

rails new PetitionApprovalableSystem --database=postgresql

Follow the SETUP STEPS

We’re going to put a simple User object on this app, like so:

rails generate devise User is_admin:boolean

Notice that Devise has created a model object for User and added its own default fields to User, which can be seen in the file db/migrate/______devise_create_users.rb, including email, encrypted_password, reset_password_token, remember_password_sent_at, remember_created_at, as well as indexes for email and reset_password_token.

As well, notice that I’ve added another boolean field called is_admin (a lazy way to do access control, but good enough for this simple demo app). I did this with the generator above, which generated this output:

Also notice that Devise has several more fields which are commented out in the default setup (using # marks) so they will not be created in this migration if you run it as-is. They are related to Devise features like Lockable, Trackable, Confirmable which are optional.

# frozen_string_literal: true

class DeviseCreateUsers < ActiveRecord::Migration[6.1]
def change
create_table :users do |t|
## Database authenticatable
t.string :email, null: false, default: ""
t.string :encrypted_password, null: false, default: ""

## Recoverable
t.string :reset_password_token
t.datetime :reset_password_sent_at

## Rememberable
t.datetime :remember_created_at

## Trackable
# t.integer :sign_in_count, default: 0, null: false
# t.datetime :current_sign_in_at
# t.datetime :last_sign_in_at
# t.string :current_sign_in_ip
# t.string :last_sign_in_ip

## Confirmable
# t.string :confirmation_token
# t.datetime :confirmed_at
# t.datetime :confirmation_sent_at
# t.string :unconfirmed_email # Only if using reconfirmable

## Lockable
# t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
# t.string :unlock_token # Only if unlock strategy is :email or :both
# t.datetime :locked_at

t.boolean :is_admin

t.timestamps null: false
end

add_index :users, :email, unique: true
add_index :users, :reset_password_token, unique: true
# add_index :users, :confirmation_token, unique: true
# add_index :users, :unlock_token, unique: true
end
end

Now that we have Devise in place, let’s create a Petitions table:

Basic App Requirements

We’re going to ask petitioners (users) 3 questions — we don’t know the questions yet — so we’ll just call them answer1, answer2, and answer3.

Each Petition record can be created by any user who is logged in.

Each Petition record will have two special timestamps: accepted_at and rejected_at. These timestamps will be editable only by admins. (People who have the special is_admin flag set on their user record.) Of course, you probably shouldn’t do this lazy-style access control on your app and instead, look at any of the User Authentication Gem for this purpose. But for this demo app, a simple is_admin boolean flag will be sufficient to demonstrate that only admins will have access to the screen at /admin/petitions.

A logged-out user will not be able to submit a Petition at all, and instead will be redirected to the Devise sign up / log in pages. Once they go through the sign-up process, they will be able to access the page to submit new petitions.

Obviously, the Petition record they create will have its user_id set to the logged-in user automatically, and the user won’t be able to hack the system and set accepted_at (or the rejected_at) fields by parameter hijacking or parameter pollution attack.

The normal user won’t see much about their application once it is submitted, but we’ll add a bit of customization to make the New Petition button disappear after one application has been submitted and show the user what date & time that record got created (we’ll use the created_at timestamp for this).

The Admin user, on the other hand, will have access to a screen where they will see all of the non-accepted and non-rejected petitions. They will see the 3 questions, but not be able to edit them.

They will be able to read the 3 answers only. They will have magic buttons for “Accept” and “Reject” allowing them to accept or reject the petition accordingly. Once accepted or rejected, the Petition will disappear from the Admin’s list view.

As well, we’ll add some tricky business logic to make it so that all 3 questions must be complete and none can be duplicates of each other before the Admin can either accept or reject.

If you’ve been paying attention, this means that since the User isn’t given the same validation requirements (3 questions must not be empty and must not duplicate each other), it will be possible to get the Petition “stuck” so that the Admin can’t approve it or reject it, and the petitioner won’t be able to update it to fix the problems.

This is to demonstrate Hot Glue’s magic button functionality. See the Additional Exercises for some ways to extend this to customize the dashboard.

For the basic exercise, we’ll just do the task described above: Enforce the field validations when the admin is accepting or rejecting, not when the petitioner is submitting the petition. So at the very end, we’ll have a product that demonstrates Hot Glue’s magic buttons but will have the UX problem described above.

Generating the Basic Models

Here’s how we’ll generate the model:

rails generate model Petition user_id:integer answer1:text answer2:text answer3:text accepted_at:timestamp rejected_at:timestamp

The file Hot Glue will create looks like

class CreatePetitions < ActiveRecord::Migration[6.1]
  def change
    create_table :petitions do |t|
      t.integer :user_id
      t.text :answer1
      t.text :answer2
      t.text :answer3
      t.timestamp :accepted_at
      t.timestamp :rejected_at

      t.timestamps
    end
  end
end

Go ahead and rails db:create and then rails db:migrate

Now let’s define our relationships. Add the code in orange.

app/models/user.rb

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  
  has_many :petitions
end

app/models/petition.rb

class Petition < ApplicationRecord
  belongs_to :user
end

Generating the Scaffolding

Remember, all of the Sign-up, Log in, and logout functionality is provided to us by Devise.

First, we’ll need scaffolding for the users who want to submit a petition.

Before we can build the Petition scaffold, we’ll need to give Petitions a label

class Petition < ApplicationRecord
  belongs_to :user

  def to_label
    "submitted by #{user.email} on #{created_at.to_date}"
  end
end

Generate the Petition model using the –no-create, –no-list flags. Here we’ve also used the –auth flag, but since we are passing its default anyway (current_user), this is optional. Remember, it is not optional if your auth object is something other than current_user.

rails generate hot_glue:scaffold Petition --include=answer1,answer2,answer3 --no-edit --no-list --auth=current_user

GO to

http://127.0.0.1:3000/petitions

Notice you are redirected to the log-in page

http://127.0.0.1:3000/users/sign_up

Why did we get redirected to the log-in page when we went to the Petitions page?

Well, the requirement above specifies that o

Now take a look into petitions_controller.rb:

class PetitionsController < ApplicationController
  helper :hot_glue
  include HotGlue::ControllerHelper

  before_action :authenticate_user!
  
  before_action :load_petition, only: [:show, :edit, :update, :destroy]
  after_action -> { flash.discard }, if: -> { request.format.symbol ==  :turbo_stream }



  

Aha! The before_action :authenticate_user! is what protects this entire controller from being used unless you are logged in by Devise.

Now use the Devise Log-in Screen to switch to the Sign up screen and create a new user. Be sure to enter the same password twice, and as enforced by Devise, your password must be at least 8 characters.

This email will be your normal user. We’ll create another user for the Admin user in the next step.

Now we get to the stored location, conveniently back to the /petitions screen where we wanted to go in the first place.

Now click “New Petition”

Without leaving the screen, we can answer the three questions. (You can easily modify the view templates to add real questions to the screens when you have them ready, but I suggest you modify your field names to be more meaningful than answer1, answer2, answer3.)

Fill out the petition and click Save

The petition is saved. We come back to the Petitions “list” screen (which is not showing a list) and it tells us the petition is saved and we can create a new petition.

To meet the requirements above, we need to make two modifications to the generated code. Once we modify the generated code, we won’t be able to re-generate and keep these modifications. This is an important caveat when using Hot Glue.

Go into controllers/petitions_controller.rb and look for the create method. Then add this ugly hack shown in orange. This is an ugly hack because it is for security only. It will only be shown to a hacker who tries to submit the parameters again. It should never be shown to the user.


def create
  if current_user.petitions.any?
    raise "Can't make more than 1 petition per user"
  end
  modified_params = modify_date_inputs_on_params(petition_params.dup.merge!(user: current_user ) , current_user)
  @petition = Petition.create(modified_params)

Now go into app/views/petitions/_list.erb and make the new button shown only when the current_user does NOT have any petitions submitted already:

<%= turbo_frame_tag "petitions-list"   do %>
  <div class="container-fluid scaffold-list">

    <% unless current_user.petitions.any? %>
      <%= render partial: "petitions/new_button", locals: {} %>
    <% else %>
    <p>
      Your petition was submitted on <%= current_user.petitions.last.created_at.strftime("%m/%d/%Y") %>
    </p>
    <p>
      We will be in touch with you at <%= current_user.email %>.
    </p>
    <% end %>
    
  </div>
<% end %>

There. Isn’t that a nice message letting the user when their petition was submitted for review?

If you go back to the browser, you’ll see:

To continue working with this user, delete the petition on the rails console:

Now if you go back to the UI in the same browser session (logged in as noone@nowhere.com, in this example), you will again see the “New Petition” button.

Go ahead and submit a petition and you will see both the “Successfully created” message and also the list page that shows you only the submission date and does not redisplay the “New Petition” button:

Great! 

We’re done with the requirements for the normal petitioner (front-facing) user. Now let’s create the Admin user’s view.

The Admin Screens

Note that while it’s not required, I’m going to use the same Devise login and use the is_admin boolean we created on the User object. There will be no link to the admin screens, so the admins will have to know to go to /admin/petitions to see the list of petitions. We will need to add routes to those screens.

Let’s start with creating the routes:

Rails.application.routes.draw do
  devise_for :users
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html

  resources :petitions, only: [:index, :new, :create]

  namespace :admin do
    resources :petitions
  end
end

Now let’s build scaffolding.

rails generate hot_glue:scaffold Petition --namespace=admin --god  --show-only=answer1,answer2,answer3 --magic-buttons=accept,reject --display-list-after-update --no-create --no-edit --no-delete

Now, go into app/controllers/admin/base_controller.rb and make the important following change to protect this entire namespace:

class Admin::BaseController < ApplicationController
  before_action :authenticate_current_admin!

  def authenticate_current_admin!
    if !current_user || !(current_user.is_admin?)
      flash[:alert] = "Not authorized"
      redirect_to petitions_path
    end
  end
end

Notice that when you go to /admin/petitions, you are then ridirected to /petitions becuase the logged-in user (noone@nowhere.com) is not an admin:

To fix this, let’s add a log out button to our app:

<%= button_to "Log Out", destroy_user_session_path, method: :delete, 'data-turbo': false %>

With this Log Out button, we can log out and then create a second user. This second user will be our admin, and will give them an email of admin@nowhere.com

Once created, open up rails console and manually make this user an admin.

Now log in as that user and go to /admin/petitions

Notice that we have a partially functional screen for the admin user:

Now, let’s take a look at the Magic Button code inside of app/controllers/admin/petitions_controller.rb

Do not confuse this with the other petitinos_controller.rb file, which does not have the magic buttons.

This is at the very top of the update method on the PetitionsController. You are not adding this code— it was already generated for you. Just examine it to study what it does.

def update
  if petition_params[:accept]
    begin
      res = @petition.accepted!
      res = "Accepted." if res === true
      flash[:notice] = (flash[:notice] || "") <<  (res ? res + " " : "")
    rescue ActiveRecord::RecordInvalid => e
      @petition.errors.add(:base, e.message)
      flash[:alert] = (flash[:alert] || "") << 'There was ane error accepting your petition: '
    end
  end
  if petition_params[:reject]
    begin
      res = @petition.reject!
      res = "Rejected." if res === true
      flash[:notice] = (flash[:notice] || "") <<  (res ? res + " " : "")
    rescue ActiveRecord::RecordInvalid => e
      @petition.errors.add(:base, e.message)
      flash[:alert] = (flash[:alert] || "") << 'There was ane error rejecting your petition: '
    end
  end

Also notice in views/admin/petitions/_show.erb, we have the magic buttons themselves. You are not adding this code— it was already generated for you. Just examine it to study what it does.


  <%= form_with model: petition, url: admin_petition_path(petition) do |f| %>
      <%= f.hidden_field :accept, value: "accept" %>
    <%= f.submit 'Accept'.html_safe, disabled: (petition.respond_to?(:acceptable?) && ! petition.acceptable? ), data: {confirm: 'Are you sure you want to accept this petition?'}, class: 'petition-button btn btn-primary ' %>
    <% end %>
<%= form_with model: petition, url: admin_petition_path(petition) do |f| %>
      <%= f.hidden_field :reject, value: "reject" %>
    <%= f.submit 'Reject'.html_safe, disabled: (petition.respond_to?(:rejectable?) && ! petition.rejectable? ), data: {confirm: 'Are you sure you want to reject this petition?'}, class: 'petition-button btn btn-primary ' %>
    <% end %>

Notice the parts in bold above.

Since we passed “accept,reject” as the parameter for --magic-buttons, Hot Glue has made the following assumptions for each of our two magic buttons (approve and reject)
• Our Petition model has accept! and reject! methods directly on the models

• Our Petition model as acceptable? and rejectable? methods directly on the models. These, on the other hand, we use a gracefully failing respond_to? to see if you’ve defined those or forgotten to, and conveniently just assumes the action is OK if there is no ____able? method defined. Our acceptable? and rejectable? methods should respond as either true or false.

The bang methods (approve! and reject!) can respond one of four ways:

• With true, in which case a generic success message will be shown in the flash notice (“Approved” or “Rejected” in this case)

• With false, in which case a generic error message will be shown in the flash alert (“Could not approve…”)

• With a string, which will be assumed to be a “success” case, and will be passed to the front-end in the alert notice.

• Raise an ActiveRecord exception

This means you can be somewhat lazy about your bang methods, but keep in mind the truth operator compares boolean true NOT the return value is truthy. So your return object must either be actually boolean true, or an object that is string or string-like (responds to .to_s). Want to just say it didn’t work? Return false. Want to just say it was OK? Return true. Want to say it was successful but provide a more detailed response? Return a string.

Finally, you can raise an ActiveRecord error which will also get passed to the user, but in the flash alert area. (Which typically should show in red as the default skins will do, to indicate a problem.) You can let ActiveRecord raise the error too, of course, by setting an invalid valid and calling save! inside of your bang method. (This makes sense because we started with a ! method, it is OK to call in turn call save! on our model, as we will do in the example here. If one of the things we want to do makes the record invalid, Hot Glue is keeping up its part of the job, picking up on the invalid case and redisplaying it to the front-end, just as it does for normal record invalidations.)

Note that in this design, we have to implement both a Rails validation and the ____able? method to determine whether to make the button light up or not. This is slightly duplicative but it is worth the payoff we get for how much functionality we have quickly built.

Enable the Accept and Reject Action

class Petition < ApplicationRecord
  belongs_to :user

  def to_label
    "submitted by #{user.email} on #{created_at.to_date}"
  end

  def accept!
    self.accepted_at = Time.current
    self.save!
  end

  def reject!
    self.rejecte_at = Time.current
    self.save!
  end
end

Accepting the Petition

Notice that when we accept the petition, it now gets stamped with the current timestamp.

Make the Petition Unable to Be Accepted/Rejected (By Rails Validation)

This is part of Rails validations. It enforces a validation that to make the Petition accepted or rejected, it must meet certain criteria. Note that the same validation is NOT enforced simply at the model level, which means that the user can submit the petition in a way that makes it unable to be accepted or rejected.

class Petition < ApplicationRecord
  belongs_to :user

  def to_label
    "submitted by #{user.email} on #{created_at.to_date}"
  end

  validate :cannot_be_accepted_or_reject_if_invalid_fields, if: -> {accepted_at || rejected_at}

  def accept!
    self.accepted_at = Time.current
    self.save!
  end

  def reject!
    self.rejecte_at = Time.current
    self.save!
  end

  def cannot_be_accepted_or_reject_if_invalid_fields
    if answer1.empty?
      errors.add(:answer1, "cannot be empty")
    end
    if answer2.empty?
      errors.add(:answer2, "cannot be empty")
    end
    if answer3.empty?
      errors.add(:answer2, "cannot be empty")
    end

    if (answer1 == answer2) || (answer2 == question3) || (answer1 == answer3)
      errors.add(:base, "cannot have duplicate answers")
    end
  end
end

Add the code above in orange to add the validation.

Now I’ve submitted another Petition with the answer “aaa” for both answer1 and answer2, which will violate our rules above.

Notice that if we now try to accept a Petition where there are duplicate answers, we get:

Make the Buttons Disable/Enabled (By a Magic ____able? button)

This feature is part of Hot Glue. Finally, we’re going to make it so easy for the Admin they won’t even be able to

Add these two methods to your model:

  def acceptable?
    !answer1.empty? && !answer2.empty? && !answer3.empty? && !(answer1 == answer2 || answer2 == answer3 || answer1 == answer3)
  end

  def rejectable?
    !answer1.empty? && !answer2.empty? && !answer3.empty? && !(answer1 == answer2 || answer2 == answer3 || answer1 == answer3)
  end

You’ll now notice that the Accept and Reject buttons are disabled automatically in the UI. This happens when we add the ___able? methods onto our models.

Additional Challenges:
  1. This has quite a bit of repeated code, especially with the same validation between accept and reject. Try to clean up some of the validation logic so the code isn’t repeated.
  2. Make it so that when the admin accepts or rejects a petition, it immediately disappears from the list view. You can do this with one or two ActiveRecord scopes on your Petition model (like scope :not_accepted_or_rejected, -> if: {...} or scope :rejected, -> if: {...}, etc ) and then make a small modification to the load_all_petitions method in the Admin::PetitionsController to restrict the load to only the scope you define (excluding the already rejected or accepted ones).
  3. Unfortunately this is a terrible design because neither the user nor the Admin can edit the Petition if doesn’t meet the validation requirements of being Approved/Rejected. As well, we never told the user about the requirements! As a challege, implement validation on the Rails models to make sure the requirements are enforced when the record is saved. That means the user won’t even be able to make the record with invalid fields at all.
  4. Let’s say we wanted to change the use cases to be kinder to rejected petitions. Instead of one petition, a petitioner may still make only one in-progress petition, but they may add another if they only have previously rejected ones.
  5. Add a feature to allow the administrator to provide feedback to the user when making either the acceptance or rejection. Make this feedback in the form of another field that is input by only the Admin and visible-only to the User.
  6. Allow the admin to email the user to ask follow up information or collect additional detials.
  7. Allow the user to make edits to the questions, but keep an audit trail or a revision history of their submissions so that each they can make a few edits and resubmit the petition at once (perhaps, for example, invalidating their old in-progress petition records).

All of the above ideas are excellent learning exercises for any new Rails developer. As well, with the foundation of the example app, you can use this to learn about how to correctly build access control into your application’s structure.