Quentin Gaultier
Quentin Gaultier

Reputation: 318

rails: adding image descriptions, migrating from has_many_attached to has_many

In my rails 7 app, products can have images. At first I used:

# products.rb


has_many_attached :images do |attachable|
    attachable.variant :thumb, resize_to_limit: [100, 100]
end

Now, I need each image to have a caption. So I created a images polymorphic record:

# products.rb
has_many :photos, as: :imageable, class_name: :Image, dependent: :destroy do

  def attach(args)
    build.attach(args)
  end
end
# images.rb

has_one_attached :file

My question is:

Is it possible to have products images variant definition in the products.rb file ?

For example a product would have thumb variants (100x100), and another model, a home could also has_many :images but with a medium (200x200) variant

resources

https://www.bigbinary.com/blog/rails-7-adds-ability-to-use-predefined-variants

https://jonsully.net/blog/a-generic-image-wrapper-for-active-storage/

Upvotes: 1

Views: 439

Answers (1)

Mathieu Le Tiec
Mathieu Le Tiec

Reputation: 501

Yes it is possible. Iterating over Jon Sullivan's post, there only are a few changes to make to be able to define variants from the parent model.

I've setup an example repo here.

It is just a proof of concept, this is not tested, I've probably forgotten cases, etc... it seems to work but I would not bet my production environment on it.

Furthermore, this code may be too "clever" and make things more difficult to maintain and debug. I suspect there is a way to make this simpler, but did not take the time to dig further.

Anyway, the TLDR is that, in order to to handle some difference between has_one and has_many relationships, we create a new method in the Imageable concern, heavily inspired by Jon's ones, and adapt the Image class a bit:

# app/model/concerns/imageable.rb

module Imageable
  extend ActiveSupport::Concern

  class_methods do
    def has_images(relation_name, **kwargs, &block)
      has_many relation_name, as: :imageable, class_name: 'Image', dependent: :destroy

      # this creates an has_one_attached relation, specific to this call of
      # has_images, to which attachment options will be set. This allows to
      # have distinct options set per call
      image_attachment = "#{name.downcase}_#{relation_name.to_s.singularize}".to_sym
      Image.has_one_attached image_attachment, **kwargs, &block

      # not strictly necessary but allows for nicer code when building forms
      accepts_nested_attributes_for relation_name

      # We override attribute writers generated by `has_many` and `accepts_nested_attributes_for`
      # to force the correct name attribute on Image
      class_eval <<-CODE, __FILE__, __LINE__ + 1
        def #{relation_name}=(images)
          super(images.map {|image|
            image.name = "#{image_attachment}"
            image
          })
        end
        # not strictly necessary but allows for nicer code when building forms
        def #{relation_name}_attributes=(attrs)
          super(attrs.transform_values {|image_attrs|
            {name: "#{image_attachment}"}.merge!(image_attrs)
          })
        end
      CODE
    end
  end
end


# app/models/image.rb

class Image < ApplicationRecord
  belongs_to :imageable, polymorphic: true
  has_one_attached :file
  delegate :persisted?, to: :imageable, allow_nil: true

  delegate_missing_to :file

  # The name attribute value is the name of the relation we create on Image
  # when calling Imageable.has_images. We call the attr_reader created by
  # the relation definition, or fallback to a generic attachment if no name
  # exists (eg on Image.new)
  def file
    name ? public_send(name) : ActiveStorage::Attached::One.new('file', self)
  end

  def file=(attrs)
    file.attach attrs unless attrs.blank?
  end
end

You can setup a relation using the newly defined has_images method

# app/model/product.rb

class Product < ApplicationRecord
  include Imageable

  has_images :photos do |attachable|
    attachable.variant :thumb, resize_to_limit: [100, 100]
    attachable.variant :medium, resize_to_limit: [300, 300]
  end
end

You can then use this in your views as in the blog post

<%# app/views/products/_form.html.erb %>

<div id="<%= dom_id product %>">
   <p>
     <strong>Images:</strong>
     <% product.photos.each do |image| %>
       <%= image_tag image.file.variant(:thumb) %>
       <%= image_tag image.file.variant(:medium) %>
     <% end %>
   </p>
 </div>
<%# app/views/products/_form.html.erb %>

<%= form_with(model: product) do |form| %>
  <% if product.errors.any? %>
    <div style="color: red">
      <h2><%= pluralize(product.errors.count, "error") %> prohibited this product from being saved:</h2>

      <ul>
        <% product.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <%= form.fields_for :photos do |photo_fields| %>
    <div>
      <%= photo_fields.label :preview %>
      <br>
      <%= image_tag(photo_fields.object.variant(:thumb)) if photo_fields.object.attached? %>
    </div>

    <div>
      <%= photo_fields.file_field :file, direct_upload: true %>
    </div>

    <div>
      <%= photo_fields.label :image_alt_text %><br>
      <%= photo_fields.text_field :alt_text, required: true %>
    </div>

    <div>
      <%= photo_fields.label :image_caption %><br>
      <%= photo_fields.text_field :caption, required: true %>
    </div>
  <% end %>

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

Upvotes: 1

Related Questions