This is one of those "real world project" problems that I am struggling to find a good solution to.
I have a task
model that is associated to several different resources and I need to allow CRUD ability from each of those associated resources.
For instance, a project
has many tasks
and I need to update the tasks
in context of the project
.
Also, each project
has many milestones
, and each milestone
also can have many tasks
.
It's a situation where a task
may or may not be associated to a milestone
.
class Project < ApplicationRecord
has_many :milestones
has_many :tasks
end
class Milestone < ApplicationRecord
belongs_to :project # required
has_many :tasks
end
class Task < ApplicationRecord
belongs_to :project # required
belongs_to :milestone # optional
end
I have namespaced my controllers for better organization and control. That leads me with routes such as:
# routes.rb
resources :projects do
namespace :projects do
resources :milestones # app/controllers/projects/milestones_controller.rb
resources :tasks # app/controllers/projects/tasks_controller.rb
end
end
resources :milestones do
namespace :milestones do
resources :tasks # app/controllers/milestones/tasks_controller.rb
end
end
I also am using a lot of AJAX to make the interface faster (with Turbolinks), so my actions follow the <action>.js.erb
format where I use JavaScript to update the page instead of page refreshes. Note that I'm using a dialog/popup interface for the task
forms as well, hence why I can't just do entire page refreshes.
While this "works", it ends up with a situation where I have a lot of duplicate code.
# projects/tasks_controller.rb
def new
@project = Project.find(params[:project_id])
@task = @project.tasks.new
end
def create
@project = Project.find(params[:project_id])
@task = @project.tasks.new(task_params)
if @task.save
# create.js.erb
else
render js: "alert('error');" # example...
end
end
# app/views/projects/tasks/create.js.erb
$("#tasks_for_<%= dom_id(@project) %>").append("<%=j render(partial: 'projects/tasks/task') %>");
# milestones/tasks_controller.rb
def new
@milestone = Milestone.find(params[:milestone_id])
@task = @milestone.tasks.new
end
def create
@milestone = Milestone.find(params[:milestone_id])
@task = @milestone.tasks.new(task_params)
if @task.save
# create.js.erb
else
render js: "alert('error');" # example...
end
end
# app/views/milestones/tasks/create.js.erb
$("#tasks_for_<%= dom_id(@milestone) %>").append("<%=j render(partial: 'milestones/tasks/task') %>");
This is just some sample code, the code from the actual system shows even more duplication. As you can tell, there's a lot of repeat code in each of the different resources that interact slightly different with the tasks
resource.
Is there some standard format or Rails feature that assists with structuring resources that are manipulated by several other resources?
How could I decrease this duplication? It is directly leading to a complex system where everytime I add or change a feature, I have to go and change it in 3+ different places.
Let's assume you have a ServiceBase
that looks something like this:
# services/service_base.rb
class ServiceBase
attr_accessor :args,
:controller
class << self
def call(args={})
new(args).call
end
end # Class Methods
#======================================================================
# Instance Methods
#======================================================================
def initialize(args)
@args = args
end
private
def params
controller.params
end
def assign_args
args.each do |k,v|
class_eval do
attr_accessor k
end
send("#{k}=",v)
end
end
end
And then a Tasks::NewService
that looks something like this:
# services/tasks/new_service.rb
class Tasks::NewService < ServiceBase
def call
assign_args
@haser = haser_klass.find(haser_id)
@haser.tasks.new
end
private
def haser_klass
haser_base.constantize
end
def haser_instance_name
haser_base.downcase
end
def haser_base
controller.class.name.split("::")[0].singularize
end
def haser_id
params["#{haser_instance_name}_id".to_sym]
end
end
Then, in your controllers, you should be able to do something like:
# milestones/tasks_controller.rb
Milestones::TasksController < ApplicationController
def new
@task = Tasks::NewService.call(controller: self)
end
end
# projects/tasks_controller.rb
Projects::TasksController < ApplicationController
def new
@task = Tasks::NewService.call(controller: self)
end
end
If you have a Tasks::CreateService
that looks something like:
# services/tasks/create_service.rb
class Tasks::CreateService < ServiceBase
delegate :render,
to: :controller
def call
assign_args
@haser = haser_klass.find(haser_id)
@task = @haser.tasks.new(task_params)
if @task.save
# create.js.erb
else
render js: "alert('error');" # example...
end
end
private
def task_params
send("#{haser_instance_name}_params")
end
def milestone_params
params.require(:milestone).permit(:foo)
end
end
Then, in your controllers, you should be able to do something like:
# milestones/tasks_controller.rb
Milestones::TasksController < ApplicationController
def new
@task = Tasks::NewService.call(controller: self)
end
def create
Tasks::CreateService.call(controller: self)
end
end
# projects/tasks_controller.rb
Projects::TasksController < ApplicationController
def new
@task = Tasks::NewService.call(controller: self)
end
def new
Tasks::CreateService.call(controller: self)
end
end
If you get to a point where, after abstraction, your controllers have identical methods, then you could start to do other fancy stuff like have Milestones::TasksController
and Projects::TasksController
both inherit from a common controller other than ApplicationController
that has all the shared methods. So, let's say you're not using TasksController
. Then, maybe your controllers could look something like:
# tasks_controller.rb
TasksController < ApplicationController
def new
@task = Tasks::NewService.call(controller: self)
end
def create
Tasks::CreateService.call(controller: self)
end
end
# milestones/tasks_controller.rb
Milestones::TasksController < TasksController; end
# projects/tasks_controller.rb
Projects::TasksController < TasksController; end
Now, you only need to make changes in one place: in your services. Naturally, you can always override controller methods if you don't want to use the services.