简体   繁体   中英

Using scopes to filter results in rails 5

I'm running into an issue with scopes in rails. Currently I have a case statement in the index controller which is causing the app to either filter on 1 category OR another category. But I need to be able to filter on multiple things at once . Basically to chain the scopes so when a user clicks on link A, scope A is applied and then when they click on link B scope B is added on top of scope A to filter even more. Eventually I would like to add a check box in the view so when it's checked the scope is applied and when unchecked the scope is removed.

I have a bunch of conditional logic (if\\else) statements that I use now because I don't just need to filter on say "experience" I need to filter on 4 different options of experience (0-2 years, 2-5 years, 5-10 years, etc...). This will be the same for all scopes.

So in summary I need to find a way to chain scopes so that the filters keep getting applied (filter A + filter B + filter C, etc...) and not have them the way they are now where it's either filter A or filter B but can't get them both to work together to further narrow down results.

here are my scopes

scope :by_experience, -> (ex) { where(experience: ex) }

scope :by_num_days_past, -> (days_past) { where('created_at >= ?', days_past.days.ago) }

here is my view

            <!-- toggle for experience -->  
            <a class="" role="" data-toggle="collapse" 
href="#experience-collapse" aria-expanded="false" aria-
controls="experience-collapse">
                Experience<br />
            </a>
            <div class="collapse" id="experience-collapse">
              <div class="well">
                <%= link_to "0-2 years", filtered_jobs_path(:experience, '0-2 years') %><br />
                <%= link_to "2-5 years", filtered_jobs_path(:experience, '2-5 years') %><br />
                <%= link_to "5-10 years", filtered_jobs_path(:experience, '5-10 years') %><br />
                <%= link_to "10+ years", filtered_jobs_path(:experience, '10+ years') %>        
              </div>
            </div>  
            <br />            

            <!-- toggle for num_days_past -->  
            <a class="" role="" data-toggle="collapse" href="#num-days-
past-collapse" aria-expanded="false" aria-controls="num-days-past-
collapse">
                Days Listed<br />
            </a>
            <div class="collapse" id="num-days-past-collapse">
              <div class="well">
                <%= link_to "7 days", filtered_jobs_path(:num_days_past, '7') %><br />
                <%= link_to "30 days", filtered_jobs_path(:num_days_past, '30') %><br />
                <%= link_to "60 days", filtered_jobs_path(:num_days_past, '60') %><br />
              </div>
            </div>  
            <br />   

Here's my controller's index action where I think the change needs to take place in order for there to be multiple scopes applied at once.

def index

      case params[:scope]  
      when "num_days_past"
        if (params[:format] == "7")
          @jobs = Job.by_num_days_past("7").paginate(page: params[:page], per_page: 5)     
        elsif (params[:format] == "30")
          @jobs = Job.by_num_days_past("30").paginate(page: params[:page], per_page: 5)     
        elsif (params[:format] == "60")
          @jobs = Job.by_num_days_past("60").paginate(page: params[:page], per_page: 5)  
         else   
             @jobs = Job.order('created_at DESC').paginate(page: params[:page], per_page: 5)        
         end      
end

Calling multiple scopes on the instance variable gets them chained. Please try following:

def index

    @jobs = Job.by_num_days_past("7") if (params[:format] == "7")     
    @jobs = @jobs&.by_num_days_past("30") || Job.by_num_days_past("30") if (params[:format] == "30")        
    @jobs = @jobs&.by_num_days_past("60") || Job.by_num_days_past("60") if (params[:format] == "60")         
    @jobs = Job.order('created_at DESC') if @jobs.blank?
    @jobs.paginate(page: params[:page], per_page: 5)

end

You could delay the pagination till you're done applying scopes. Like so:

def index
  case params[:scope]  
  when "num_days_past"
    if (params[:format] == "7")
      @jobs = Job.by_num_days_past("7")
    elsif (params[:format] == "30")
      @jobs = Job.by_num_days_past("30")
    elsif (params[:format] == "60")
      @jobs = Job.by_num_days_past("60")
    else
      @jobs = Job.all
    end 

  # Now you can apply scope 2
  # For example
  @jobs = @jobs.by_experience(params[:exp]) if params[:scope_by_experience]

  @jobs = @jobs.order('created_at DESC').paginate(page: params[:page], per_page: 5)        
end

The #paginate implementation might be executing the query instantly and the result wouldn't be an Active Record object that you can apply further scopes on.

The problem:

The reason it currently doesn't work is you don't save the current filter/scope. If you click a second filter you have no clue what scope was previously rendered.

To fix this issue you need to store the filter on the client and send it as param on the next request. This is generally done in the URL, this way you can share the website with filters. If someone else goes to the link the filters automatically jump to the correct position. But it can also be done through cookies.

For example take a look at:

I know they may be foreign languages to you (Dutch), but take a close look at the URL bar when turning filters on and off. The filters are saved and passed on with the next request you make.

A posible solution:

Currently you created a dedicated route for the filter, my guess is:

resources :jobs do
  get '/:scope/:format', on: :collection, as: :filtered, action: :index
end

What you should do is use the jobs_path with params like so ( view ):

jobs_path(number_of_days_past: 7, experience: '0-2 years')

This will generate the URL:

/jobs?number_of_days_past=7&experience=0-2+years

If you want to combine a new filter with previous filters you have to extract the previous filters first. This is done in the controller like so:

def index
  @filters = params.permit(:number_of_days_past, :experience).to_h
end

Create a helper method for combining the @filters with the filters you want to apply or remove ( app/helpers/jobs_helper.rb ). The example below can toggle a filter on and off and supports multiple filters, but not multiple values for a given filter. If you want support for multiple values per filter, take this as an example and edit it to your liking.

def combine_filter(apply_filters)
  apply_filters.each_with_object(@filters.deep_dup) do |(key, value), filters|
    filters[key] == value ? filters.delete(key) : filters[key] = value
  end
end

# OR, if the above did go to fast.

def combine_filter(apply_filters)
  # duplicate the @filters hash, this way multiple calls don't influence each other
  filters = @filters.deep_dup
  # loop through apply_filters (Hash) and check every filter
  apply_filters.each do |key, value|
    if filters[key] == value
      # filter content was the same, toggle it off
      filters.delete(key)
    else
      # filter content was not the same, replace value for this filter
      filters[key] = value 
    end
  end
  # return the filters variable
  filters
end

This will leave you with a view like this:

<div class="well">
    <%= link_to "0-2 years", jobs_path(combine_filter(experience: '0-2 years')) %><br />
    <%= link_to "2-5 years", jobs_path(combine_filter(experience: '2-5 years')) %><br />
    <%= link_to "5-10 years", jobs_path(combine_filter(experience: '5-10 years')) %><br />
    <%= link_to "10+ years", jobs_path(combine_filter(experience: '10+ years')) %>        
</div>

At this point when receiving a request in the controller you have all filters in the variable @filters but still need to handle them. Check the online resources below for an example.

Online help and resources:

Here is a blogpost on the how to implement a simple version (handling the incoming filters and calling the correct scopes):

But you could also try to find the right gem for the job, here is one that I found with a quick Google search :

So I ended up using a combination of several of the answers. Thanks to Oscar for explaining about holding off pagination until last, Ahmand thanks for the info about chaining, Johan thanks for explaining about the jobs_path params. I would up vote but it says I don't have enough rep points yet. Posting my answer here in case anyone stumbles upon this in the future.

In the controller I start with Job.all and then build the scopes on top of that, finally applying the pagination. I feel like I should move all this to a method in the model though and then just call it from the controller. But it's working the way it is, I'll test moving it to the model later.

def index
  @jobs = Job.all

  # scopes
  if params[:experience].present? 
    @jobs = @jobs.by_experience(params[:experience])
  end


  if params[:num_days_past].present? 
    @jobs = @jobs.by_num_days_past(params[:num_days_past].to_i)
  end      


  @jobs = @jobs.paginate(page: params[:page], per_page: 5)
end

In my view I have added the experience params to the link so that the url will display both experience and filter by days if the parameter is present.

      <div class="well">
            <%= link_to "7 days", filtered_jobs_path(num_days_past: '7', experience: params[:experience]) %><br />
            <%= link_to "30 days", filtered_jobs_path(num_days_past: '30', experience: params[:experience]) %><br />
            <%= link_to "60 days", filtered_jobs_path(num_days_past: '60', experience: params[:experience]) %><br />
      </div>

Here's my model with the scopes

 # scopes to filter results from database.
scope :by_created_at, -> { order(created_at: :desc) }
scope :by_experience, -> (ex) { where(experience: ex) if ex.present? }
scope :by_num_days_past, -> (days_past) { where('created_at >= ?', days_past.days.ago) if days_past.present? }
scope :by_location, -> (location) { where('city LIKE ? OR state LIKE ?', "%#{location}%", "%#{location}%") }

Here are my routes, I removed the /filer/:scope that I previously had.

  #get 'jobs/filter/:scope' => 'jobs#index', as: :filtered_jobs
  get 'jobs' => 'jobs#index', as: :filtered_jobs

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM