Dan Tappin
Dan Tappin

Reputation: 3032

Rails Active Storage - Keep Existing Files / Uploads?

I have a Rails model with:

has_many_attached :files

When uploading via Active Storage by default if you upload new files it deletes all the existing uploads and replaces them with the new ones.

I have a controller hack from this which is less than desirable for many reasons:

What is the correct way to update images with has_many_attached in Rails 6

Is there a way to configure Active Storage to keep the existing ones?

Upvotes: 9

Views: 4677

Answers (4)

In Rails 7.1 the default option for = f.file_field :files, multiple: true, include_hidden: true You can check it in dev/tools in browser This option means that form submission will include hidden And hidden are id's of existing attachments

<% @message.files.each do |file| %>
<%= f.hidden_field :files, multiple: true, value: file.signed_id %>
<% end %>

[3.4 Replacing vs Adding Attachments][1] [1]: https://edgeguides.rubyonrails.org/active_storage_overview.html#replacing-vs-adding-attachments

So that is a solution!

Upvotes: 0

alex
alex

Reputation: 1089

As of Rails 7, you can call include_hidden: false in your form to stop this happening.

<%= form.file_field :images, multiple: true, include_hidden: false %>

More details on the file_field documentation.

:include_hidden - When multiple: true and include_hidden: true, the field will be prefixed with an field with an empty value to support submitting an empty collection of files.

https://edgeapi.rubyonrails.org/classes/ActionView/Helpers/FormBuilder.html#method-i-file_field

Upvotes: 0

chedli
chedli

Reputation: 291

The solution suggested for overwriting the writer by @edariedl DOES NOT WORK because it causes a stack level too deep

1st solution

Based on ActiveStorage source code at this line

You can override the writer for the has_many_attached like so:

class Model < ApplicationModel
  has_many_attached :files

  def files=(attachables)
     attachables = Array(attachables).compact_blank

    if attachables.any?
      attachment_changes["files"] =
        ActiveStorage::Attached::Changes::CreateMany.new("files", self, files.blobs + attachables)
    end
  end
end

Refactor / 2nd solution

You can create a model concern that will encapsulate all this logic and make it a bit more dynamic, by allowing you to specify the has_many_attached fields for which you want the old behaviour, while still maintaining the new behaviour for newer has_many_attached fields, should you add any after you enable the new behaviour.

in app/models/concerns/append_to_has_many_attached.rb

module AppendToHasManyAttached
  def self.[](fields)
    Module.new do
      extend ActiveSupport::Concern

      fields = Array(fields).compact_blank # will always return an array ( worst case is an empty array)

      fields.each do |field|
        field = field.to_s # We need the string version
        define_method :"#{field}=" do |attachables|
          attachables = Array(attachables).compact_blank

          if attachables.any?
            attachment_changes[field] =
              ActiveStorage::Attached::Changes::CreateMany.new(field, self, public_send(field).public_send(:blobs) + attachables)
          end
        end
      end
    end
  end
end

and in your model :

class Model < ApplicationModel
  include AppendToHasManyAttached['files'] # you can include it before or after, order does not matter, explanation below

  has_many_attached :files
end

NOTE: It does not matter if you prepend or include the module because the methods generated by ActiveStorage are added inside this generated module which is called very early when you inherit from ActiveRecord::Base here

==> So your writer will always take precedence.

Alternative/Last solution:

If you want something even more dynamic and robust, you can still create a model concern, but instead you loop inside the attachment_reflections of your model like so :

reflection_names = Model.reflect_on_all_attachments.filter { _1.macro == :has_many_attached }.map { _1.name.to_s } # we filter to exclude `has_one_attached` fields
# => returns ['files']
reflection_names.each do |name|
  define_method :"#{name}=" do |attachables|
  # ....
  end
end

However I believe for this to work, you need to include this module after all the calls to your has_many_attached otherwise it won't work because the reflections array won't be fully populated ( each call to has_many_attached appends to that array)

Upvotes: 8

edariedl
edariedl

Reputation: 3352

Looks like there is a configuration that does exactly that

config.active_storage.replace_on_assign_to_many = false

Unfortunately it is deprecated according to current rails source code and it will be removed in Rails 7.1

config.active_storage.replace_on_assign_to_many is deprecated and will be removed in Rails 7.1. Make sure that your code works well with config.active_storage.replace_on_assign_to_many set to true before upgrading. To append new attachables to the Active Storage association, prefer using attach. Using association setter would result in purging the existing attached attachments and replacing them with new ones.

It looks like explicite usage of attach will be the only way forward.

So one way is to set everything in the controller:

def update
  ...
  if model.update(model_params)
    model.files.attach(params[:model][:files]) if params.dig(:model, :files).present?
  else
    ...
  end
end

If you don't like to have this code in controller. You can for example override default setter for the model eg like this:

class Model < ApplicationModel
  has_many_attached :files

  def files=(attachables)
    files.attach(attachables)
  end
end

Not sure if I'd suggest this solution. I'd prefer to add new method just for appending files:

class Model < ApplicationModel
  has_many_attached :files

  def append_files=(attachables)
    files.attach(attachables)
  end
end

and in your form use

  <%= f.file_field :append_files %>

It might need also a reader in the model and probably a better name, but it should demonstrate the concept.

Upvotes: 23

Related Questions