Multiple Images with Rails 7: Making Images Sortable
Published: 2023-01-26 06:44:21

Note: This post will eventually be part of a series about a complete solution for users to upload and manage multiple images that are attached to a record.

---

At this point, you should have image hosting set up, you can upload multiple images, and they should persist when editing the record.

Next, we want to add the ability to re-arrange the order of the images and for the order to persist.

To do this we are going to use a few tools:
Importmaps, stimulus, RequestJS, acts_as_list gem, and a js library called sortable_js.

We will start off by setting up all of the required dependencies.

## acts_as_list setup
https://github.com/brendon/acts_as_list
1. Add `gem 'acts_as_list', '~> 1.0.4'` to your Gemfile, and run `bundle install`

2. Add a position column to ActiveStorageAttachments table.
`rails g migration AddPostionColumnToActiveStorageAttachments position:integer`.

This is what the acts_as_list gem will use to manage the order of the images.

3. Crate an initializer so act_as_list can access the ActiveStorage Model

```ruby
# config/initializers/active_storage_acts_as_list.rb
module ActiveStorageAttachmentList
  extend ActiveSupport::Concern

  included do
    acts_as_list scope: %i[record_id record_type name]
    default_scope { order(:position) }
  end
end

Rails.configuration.to_prepare do
  ActiveStorage::Attachment.send :include, ActiveStorageAttachmentList
end
```
Don't forget to restart the server whenever you add or update your initializers.


## sortable_js setup
https://github.com/rails/requestjs-rails

1. Run `bin/importmap pin sortablejs`
2. Create the stimulus controller. run `rails g stimulus sortable`
4. Setup the controller
```javascript
// app/assets/javascript/controllers/sortable_controller.js
import { Controller } from "@hotwired/stimulus"
import Sortable from "sortablejs" // Add this line, to access the sortablejs library
// Connects to data-controller="sortable"
export default class extends Controller {
  connect() {
    console.log("Sortable controller connected") // I add this for testing purposes. Once everything is working you can comment this line out.
  }
}
```
5. Connect the view to the controller
```ruby.erb
# app/views/cars/_form.html.erb
<div class="images-wrapper" data-controller="sortable" >
   <% @car.images.each do |image| %>
      <div class="form-image-card">
         <%= image_tag image, class:"form-image" %>
      </div>
   <% end %>
</div>
```
Now when you load the page, in the browser console, you should see `Sortable controller connected`

### RequestJS Set up
https://github.com/rails/requestjs-rails
1. Add the requestjs-rails gem to your Gemfile: gem 'requestjs-rails'
2. Run ./bin/bundle install.
3. Run ./bin/rails requestjs:install
4. Add `import { FetchRequest } from '@rails/request.js'` to the sortable controller.

```javascript
// app/assets/javascript/controllers/sortable_controller.js
import { Controller } from "@hotwired/stimulus"
import Sortable from "sortablejs"
import { FetchRequest } from '@rails/request.js'

// Connects to data-controller="sortable"
export default class extends Controller {
   ...
```
And that's it for setting up our dependencies. Now it's on to the code.

## Adding drag and drop functionality

In our `sortable` stimulus controller, we want to connect the parent element that contains the images we want to sort. So in our view file, we already have:

```ruby
# app/views/cars/_form.html.erb
<div class="images-wrapper" data-controller="sortable" >
   <% @car.images.each do |image| %>
      <div class="form-image-card">
         <%= image_tag image, class:"form-image" %>
      </div>
   <% end %>
</div>
```

Now we want to create a sortable element out of the "images-wrapper" div.
```javascript
// app/assets/javascript/controllers/sortable_controller.js
...
// Connects to data-controller="sortable"
export default class extends Controller {
  connect() {
    console.log("Sortable controller connected") # I add this for testing purposes. Once everything is working you can comment this out.
    // 'this' is the element in the view that is connecting to this controller. 
    this.sortable = Sortable.create(this.element, {
      animation: 150,
    })
  }
}
```

Now, back in your view, you should now be able to drag and drop the images, to change their order. Huzzah! The only issue is that it doesn't persistent yet, so as soon as you reload the page the image order won't have saved.

## Persisting the order
This is where acts_as_list gem comes into play. To start off, we will create a route that we will use to trigger the controller action that will save the updated order of the images when an image has been dragged and dropped.

### routes 
```ruby
# config/routes.rb
resources cars do
   member do
      patch :move_image
   end
end
```
This will give us a path of `/cars/:id/move_image` that will run the action `move_image` in the `car` controller.

### controller
Next, we will set up the controller by adding the following action
```ruby
# app/controllers/cars_controller.rb
...
   # /cars/:id/move_image
   def move_image
    #Find the car who's images we are re-arranging
    @car= GeneralListing.find(params[:id])
    #Find the image we are moving
    @image = @car.images[params[:old_position].to_i - 1]
    # Use the insert_at method we get from acts_as_list gem
    @image.insert_at(params[:new_position].to_i)
    head :ok
  end
...
```

### stimulus controller
When an image is dropped we want our stimulus controller to send the :move_image patch request.
```javascript
import { Controller } from "@hotwired/stimulus"
import Sortable from "sortablejs"
import { FetchRequest } from '@rails/request.js'

// Connects to data-controller="sortable"
export default class extends Controller {

  static values = {
    url: String,
    test: String
  }

  connect() {
    console.log("Sortable controller connected")
    this.sortable = Sortable.create(this.element, {
      animation: 150,
      ghostClass: "sortable-ghost",
      chosenClass: "sortable-chosen",
      dragClass: "sortable-drag",
      onEnd: this.end.bind(this)
    })
  }

  async end(event) {
    console.log("Sortable onEnd triggered")
    
    const request = new FetchRequest('patch', `${this.urlValue}?old_position=${event.oldIndex + 1}&new_position=${event.newIndex + 1}`)
    const response = await request.perform()
    console.log(response)
  }

}
```


and that should be it. The user should now be able to drag and drop images, and the order should persist.

Starting out in Rails can be intimidating so if I've missed anything or if you are unsure of any of the steps, comment below and I will update the post for clarity.

---
References
Gorails Tutorial
https://gorails.com/episodes/sortable-drag-and-drop
https://github.com/gorails-screencasts/sortable-drag-and-drop

Gorails Forum
https://gorails.com/forum/sorting-images-using-active-storage

Drifting Rails Tutorial - Setting up Import Maps
https://www.driftingruby.com/episodes/importmaps-in-rails-7

Code with Pete Tutorial
https://www.youtube.com/watch?v=FKAMRLQpypk

Request JS
https://www.youtube.com/watch?v=ACChhA4GdfM

Request JS
https://github.com/rails/requestjs-rails

Adding acts_as_list to active storage
https://gist.github.com/kwent/050b0a580fa635e5aaa225ea3a1dd846

Sortable JS
https://sortablejs.github.io/Sortable/