Reputation: 318
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
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
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