diff --git a/packages/_examples/demo/app/models/user.rb b/packages/_examples/demo/app/models/user.rb index 0ab5807a9..ce4b4adb8 100644 --- a/packages/_examples/demo/app/models/user.rb +++ b/packages/_examples/demo/app/models/user.rb @@ -1,3 +1,4 @@ class User < ApplicationRecord has_one :document, as: :documentable + has_one_attached :avatar end diff --git a/packages/_examples/demo/lib/forest_admin_rails/create_agent.rb b/packages/_examples/demo/lib/forest_admin_rails/create_agent.rb index 5e7c556b3..718e92cb1 100644 --- a/packages/_examples/demo/lib/forest_admin_rails/create_agent.rb +++ b/packages/_examples/demo/lib/forest_admin_rails/create_agent.rb @@ -19,6 +19,7 @@ def self.setup! 'User' => 'Customer', }) # .add_datasource(mongo_datasource) + @agent.use(ForestAdminRails::Plugins::ActiveStorage, { download_images_on_list: true }) customize @agent.build diff --git a/packages/forest_admin_rails/Gemfile-test b/packages/forest_admin_rails/Gemfile-test index aa75d4834..8e79abb78 100644 --- a/packages/forest_admin_rails/Gemfile-test +++ b/packages/forest_admin_rails/Gemfile-test @@ -5,6 +5,7 @@ gemspec group :development, :test do gem 'forest_admin_agent', path: '../forest_admin_agent' + gem 'forest_admin_datasource_customizer', path: '../forest_admin_datasource_customizer' gem 'forest_admin_datasource_active_record', path: '../forest_admin_datasource_active_record' gem 'rspec-rails', '~> 6.0.0' gem 'simplecov', "~> 0.22", require: false diff --git a/packages/forest_admin_rails/lib/forest_admin/types.rb b/packages/forest_admin_rails/lib/forest_admin/types.rb index d4f1f28eb..07f13b124 100644 --- a/packages/forest_admin_rails/lib/forest_admin/types.rb +++ b/packages/forest_admin_rails/lib/forest_admin/types.rb @@ -128,6 +128,17 @@ module Types # Plugin - base class for creating custom plugins Plugin = ForestAdminDatasourceCustomizer::Plugins::Plugin + # ActiveStoragePlugin - plugin to expose Active Storage attachments as File fields + # Lazy-loaded because Zeitwerk may not have autoloaded it yet when types.rb is required + def self.const_missing(name) + if name == :ActiveStoragePlugin + require 'forest_admin_rails/plugins/active_storage' + const_set(:ActiveStoragePlugin, ForestAdminRails::Plugins::ActiveStorage) + else + super + end + end + # ============================================ # Additional Context Classes # ============================================ diff --git a/packages/forest_admin_rails/lib/forest_admin_rails/plugins/active_storage.rb b/packages/forest_admin_rails/lib/forest_admin_rails/plugins/active_storage.rb new file mode 100644 index 000000000..c2c8e501f --- /dev/null +++ b/packages/forest_admin_rails/lib/forest_admin_rails/plugins/active_storage.rb @@ -0,0 +1,130 @@ +require 'base64' +require 'cgi' +require 'rack/mime' + +module ForestAdminRails + module Plugins + class ActiveStorage < ForestAdminDatasourceCustomizer::Plugins::Plugin + ACTIVE_STORAGE_COLLECTIONS = %w[ + ActiveStorage__Attachment + ActiveStorage__Blob + ActiveStorage__VariantRecord + ].freeze + + def run(datasource_customizer, _collection_customizer = nil, options = {}) + datasource_customizer.collections.each do |name, collection| + next if options[:only] && !options[:only].include?(name) + next if options[:except]&.include?(name) + + add_attachment_fields(collection, options) + end + + remove_active_storage_collections(datasource_customizer) if options.fetch(:hide_internal_collections, true) + end + + private + + def remove_active_storage_collections(datasource_customizer) + ACTIVE_STORAGE_COLLECTIONS.each do |collection_name| + next unless datasource_customizer.collections.key?(collection_name) + + datasource_customizer.remove_collection(collection_name) + end + end + + def add_attachment_fields(collection, options) + model_class = find_model_class(collection) + return unless model_class + return unless defined?(::ActiveStorage) && model_class.respond_to?(:reflect_on_all_attachments) + + model_class.reflect_on_all_attachments + .select { |a| a.macro == :has_one_attached } + .each do |attachment| + name = attachment.name.to_s + hide_attachment_relations(collection, name) + add_file_field(collection, model_class, name, options.fetch(:download_images_on_list, false)) + end + end + + def hide_attachment_relations(collection, attachment_name) + %W[#{attachment_name}_attachment #{attachment_name}_blob].each do |relation_name| + next unless collection.schema[:fields].key?(relation_name) + + collection.remove_field(relation_name) + end + end + + def add_file_field(collection, model_class, attachment_name, download_images_on_list) + return if collection.schema[:fields][attachment_name] + + collection.add_field(attachment_name, + ForestAdminDatasourceCustomizer::Decorators::Computed::ComputedDefinition.new( + column_type: 'File', + dependencies: ['id'], + values: lambda { |records, _ctx| + compute_values(records, model_class, attachment_name, download_images_on_list) + } + )) + + collection.replace_field_writing(attachment_name) do |value, context| + handle_write(value, context, model_class, attachment_name) + end + end + + def compute_values(records, model_class, attachment_name, download_images_on_list) + ids = records.map { |r| r['id'] } + models = model_class + .where(id: ids) + .includes(:"#{attachment_name}_attachment", :"#{attachment_name}_blob") + .index_by(&:id) + + records.map do |record| + model = models[record['id']] + next nil unless model&.public_send(attachment_name)&.attached? + + blob = model.public_send(attachment_name).blob + + if records.length == 1 || (download_images_on_list && blob.content_type&.start_with?('image/')) + content = blob.download + "data:#{blob.content_type};name=#{CGI.escape(blob.filename.to_s)};base64,#{Base64.strict_encode64(content)}" + else + "data:#{blob.content_type};name=#{CGI.escape(blob.filename.to_s)};base64," + end + end + end + + def handle_write(value, context, model_class, attachment_name) + record_id = context.filter&.condition_tree&.value + return {} unless record_id + + record = model_class.find(record_id) + + if value.nil? || value.to_s.strip.empty? + record.public_send(attachment_name).purge if record.public_send(attachment_name).attached? + else + parsed = ForestAdminAgent::Utils::Schema::ForestValueConverter.parse_data_uri(value) + if parsed + fallback_extension = Rack::Mime::MIME_TYPES.invert[parsed['mime_type']] || '.bin' + record.public_send(attachment_name).attach( + io: StringIO.new(parsed['buffer']), + filename: parsed['name'] || "#{attachment_name}#{fallback_extension}", + content_type: parsed['mime_type'] + ) + end + end + {} + end + + def find_model_class(collection) + current = collection.respond_to?(:collection) ? collection.collection : collection + loop do + return current.model if current.respond_to?(:model) && current.model.is_a?(Class) + break unless current.respond_to?(:child_collection) + + current = current.child_collection + end + nil + end + end + end +end diff --git a/packages/forest_admin_rails/spec/lib/forest_admin_rails/plugins/active_storage_spec.rb b/packages/forest_admin_rails/spec/lib/forest_admin_rails/plugins/active_storage_spec.rb new file mode 100644 index 000000000..78816d588 --- /dev/null +++ b/packages/forest_admin_rails/spec/lib/forest_admin_rails/plugins/active_storage_spec.rb @@ -0,0 +1,207 @@ +# rubocop:disable RSpec/VerifiedDoubles +require 'spec_helper' +require 'base64' +require 'cgi' +require 'forest_admin_datasource_customizer' +require 'forest_admin_agent' + +# Stub ActiveStorage if not available +module ActiveStorage; end unless defined?(ActiveStorage) + +require_relative '../../../../lib/forest_admin_rails/plugins/active_storage' + +RSpec.describe ForestAdminRails::Plugins::ActiveStorage do + subject(:plugin) { described_class.new } + + let(:datasource_customizer) do + instance_double(ForestAdminDatasourceCustomizer::DatasourceCustomizer, collections: collections) + end + let(:collections) { { 'Order' => order_collection, 'User' => user_collection } } + let(:order_collection) { build_collection(order_model_class) } + let(:user_collection) { build_collection(user_model_class) } + + let(:order_model_class) do + klass = Class.new do + def self.reflect_on_all_attachments; end + def self.where(_conditions); end + def self.find(_id); end + end + allow(klass).to receive(:reflect_on_all_attachments).and_return([document_attachment]) + klass + end + + let(:user_model_class) do + klass = Class.new do + def self.reflect_on_all_attachments; end + end + allow(klass).to receive(:reflect_on_all_attachments).and_return([]) + klass + end + + let(:document_attachment) do + double(:attachment, name: :document, macro: :has_one_attached) + end + + def build_collection(model_class) + decorator = Struct.new(:model).new(model_class) + collection = Struct.new(:schema, :added_fields, :write_handlers, :removed_fields) do + def add_field(name, definition) + added_fields[name] = definition + end + + def replace_field_writing(name, &block) + write_handlers[name] = block + end + + def remove_field(name) + removed_fields << name + end + end.new({ fields: {} }, {}, {}, []) + + collection.define_singleton_method(:collection) { decorator } + collection + end + + describe '#run' do + it 'adds file field for has_one_attached' do + plugin.run(datasource_customizer, nil, {}) + + expect(order_collection.added_fields).to have_key('document') + expect(order_collection.write_handlers).to have_key('document') + end + + it 'skips collections without attachments' do + plugin.run(datasource_customizer, nil, {}) + + expect(user_collection.added_fields).to be_empty + end + + context 'with :only option' do + it 'only processes whitelisted collections' do + plugin.run(datasource_customizer, nil, { only: ['User'] }) + + expect(order_collection.added_fields).to be_empty + end + end + + context 'with :except option' do + it 'skips blacklisted collections' do + plugin.run(datasource_customizer, nil, { except: ['Order'] }) + + expect(order_collection.added_fields).to be_empty + end + end + + it 'skips if field already exists' do + order_collection.schema = { fields: { 'document' => true } } + + plugin.run(datasource_customizer, nil, {}) + + expect(order_collection.added_fields).to be_empty + end + end + + describe '#compute_values (via add_field)' do + let(:blob) do + double(:blob, + content_type: 'application/pdf', + filename: double(:filename, to_s: 'report.pdf'), + download: 'file-content') + end + + let(:attachment_proxy) do + double(:attachment_proxy, attached?: true, blob: blob) + end + + let(:model_instance) do + double(:order, id: 1).tap do |inst| + allow(inst).to receive(:public_send).with('document').and_return(attachment_proxy) + end + end + + let(:query_result) do + double(:relation).tap do |qr| + allow(qr).to receive_messages(includes: qr, index_by: { 1 => model_instance }) + end + end + + before do + allow(order_model_class).to receive(:where).and_return(query_result) + plugin.run(datasource_customizer, nil, {}) + end + + it 'returns full data URI for single record (detail view)' do + computed_def = order_collection.added_fields['document'] + records = [{ 'id' => 1 }] + result = computed_def.get_values(records, nil) + + expected = "data:application/pdf;name=#{CGI.escape("report.pdf")};base64,#{Base64.strict_encode64("file-content")}" + expect(result).to eq([expected]) + end + + it 'returns metadata-only data URI for multiple records (list view)' do + computed_def = order_collection.added_fields['document'] + records = [{ 'id' => 1 }, { 'id' => 2 }] + result = computed_def.get_values(records, nil) + + expect(result.first).to eq("data:application/pdf;name=#{CGI.escape("report.pdf")};base64,") + end + + it 'returns nil for records without attachments' do + no_attachment = double(:relation) + allow(no_attachment).to receive_messages(includes: no_attachment, index_by: {}) + allow(order_model_class).to receive(:where).and_return(no_attachment) + + computed_def = order_collection.added_fields['document'] + records = [{ 'id' => 99 }] + result = computed_def.get_values(records, nil) + + expect(result).to eq([nil]) + end + end + + describe '#handle_write (via replace_field_writing)' do + let(:attachment_proxy) { double(:attachment_proxy) } + let(:model_instance) do + double(:order).tap do |inst| + allow(inst).to receive(:public_send).with('document').and_return(attachment_proxy) + end + end + + let(:condition_tree) { double(:condition_tree, value: 42) } + let(:filter) { double(:filter, condition_tree: condition_tree) } + let(:context) { double(:context, filter: filter) } + + before do + allow(order_model_class).to receive(:find).with(42).and_return(model_instance) + plugin.run(datasource_customizer, nil, {}) + end + + it 'purges attachment when value is nil' do + allow(attachment_proxy).to receive(:attached?).and_return(true) + allow(attachment_proxy).to receive(:purge) + + write_handler = order_collection.write_handlers['document'] + result = write_handler.call(nil, context) + + expect(result).to eq({}) + expect(attachment_proxy).to have_received(:purge) + end + + it 'attaches file from data URI' do + data_uri = "data:image/png;base64,#{Base64.strict_encode64("png-data")}" + allow(attachment_proxy).to receive(:attach) + + write_handler = order_collection.write_handlers['document'] + result = write_handler.call(data_uri, context) + + expect(result).to eq({}) + expect(attachment_proxy).to have_received(:attach).with( + io: an_instance_of(StringIO), + filename: 'document.png', + content_type: 'image/png' + ) + end + end +end +# rubocop:enable RSpec/VerifiedDoubles