Benito Serna Tips and tools for Ruby on Rails developers

Simple image manager with active storage

May 22, 2023

If you want to add images to a record but you don’t want to use a JavaScript plugin or write any custom JavaScript, you can use a regular file field, Active Storage, and vanilla Rails.

If you want to be able to:

Here is a tutorial to help you accomplish that.

Example: A product with many attached images

To use as an example for the tutorial, we are going to have a Product record that has many attached images like this:

class Product < ApplicationRecord
  has_many_attached :images
end

Add many images on create and every update

If you want to add many attachments to a record using just a file field, but you don’t want to remove the previous images from the record on every update, you can use a virtual attribute “new_images”.

Instead of using the :images attribute, you can add a virtual :new_images attribute, using an attr_reader and a custom writer like this:

class Product < ApplicationRecord
  attr_reader :new_images

  has_many_attached :images

  def new_images=(images)
    self.images.attach(images)
  end
end

And use that attribute in your file_field:

<%= form_with(model: product) do |form| %>
    <%= form.label :new_images %>
    <%= form.file_field :new_images, multiple: true %>
  <%= form.submit %>
<% end %>

Also, update your “params method” in the controller:

def create
  @product = @site.products.new(product_params)
  #...
end

def update
  @product.update(product_params)
  # ...
end

private

def product_params
  params.require(:product).permit(new_images: [])
end

This way, when you assign new images, your record will attach the received images instead of updating the images field with the new images.

Display the images

To display the images, you can put an HTML like this:

<div class="product-images">
  <% product.persisted_images.each do |image| %>
    <figure>
      <%= image_tag image %>
    </figure>
  <% end %>
</div>

Where “persisted_images” is:

def persisted_images
  images.select(&:persisted?)
end

Maybe you don’t need this, but I use this HTML inside the form, and I had some troubles when the record was not valid; it was showing images without content, this solved my problem.

Remove the images one by one

If you are not displaying the images inside the form, maybe you can put a button_to remove the image near the image.

<div class="product-images">
  <% product.persisted_images.each do |image| %>
    <figure>
      <%= image_tag image %>
      <%= button_to(
        "Remove",
        product_image_path(product, image),
        method: :delete) %>
    </figure>
  <% end %>
</div>

But if you want to put the images inside your form, you can’t use a button_to because you can’t have a form inside a form.

So one thing you can do is to have a button that will trigger a form that is outside the main form, like this:

<%= form_with(model: product) do |form| %>
  <div>
    <%= form.label :new_images %>
    <%= form.file_field :new_images, multiple: true %>
  </div>

  <div class="product-images">
    <% product.persisted_images.each do |image| %>
      <figure>
        <%= image_tag image %>
        <%= tag.button(
          "Remove",
          type: "submit",
          form: "delete_image_#{image.id}") %>
      </figure>
    <% end %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end %>

<% product.persisted_images.each do |image| %>
  <%= form_with(
    model: image,
    url: product_image_path(product, image),
    method: :delete,
    id: "delete_image_#{image.id}") do %>
  <% end %>
<% end %>

In the form, we are using a button tag of type submit that will trigger a form with id "delete_image_#{image.id}".

<%= tag.button(
  "Remove",
  type: "submit",
  form: "delete_image_#{image.id}") %>

And we are creating a form for each image specifying that id:

<% product.persisted_images.each do |image| %>
  <%= form_with(
    model: image,
    url: product_image_path(product, image),
    method: :delete,
    id: "delete_image_#{image.id}") do %>
  <% end %>
<% end %>

Then on the controller that handles the product_image_path, you can remove the image with purge.

class ImagesController < ApplicationController
  before_action :set_product

  def destroy
    @product.images.find(params[:id]).purge
    #...
  end

  private

  def set_product
    @product = Product.find(params[:product_id])
  end
end

Sample Code

I have a sample application with code available at https://github.com/bhserna/simpleimagemanagment.

Related articles

Weekly tips and tools for Ruby on Rails developers

I send an email each week, trying to share knowledge and fixes to common problems and struggles for ruby on rails developers, like How to fetch the latest-N-of-each record or How to test that an specific mail was sent or a Capybara cheatsheet. You can see more examples on Most recent posts or All post by topic.