Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/_examples/demo/app/models/user.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
class User < ApplicationRecord
has_one :document, as: :documentable
has_one_attached :avatar
end
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/forest_admin_rails/Gemfile-test
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions packages/forest_admin_rails/lib/forest_admin/types.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ============================================
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 8): run [qlty:function-complexity]

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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): add_file_field [qlty:function-parameters]

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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): compute_values [qlty:function-parameters]

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 8): compute_values [qlty:function-complexity]

end

def handle_write(value, context, model_class, attachment_name)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): handle_write [qlty:function-parameters]

record_id = context.filter&.condition_tree&.value
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low plugins/active_storage.rb:97

Line 97 calls context.filter&.condition_tree&.value, assuming condition_tree is always a leaf node with a value attribute. When the filter contains compound conditions (AND/OR), condition_tree is a branch node that doesn't respond to .value, so the code throws NoMethodError: undefined method 'value'. Consider checking that condition_tree responds to value before accessing it, or handle the compound case explicitly.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/forest_admin_rails/lib/forest_admin_rails/plugins/active_storage.rb around line 97:

Line 97 calls `context.filter&.condition_tree&.value`, assuming `condition_tree` is always a leaf node with a `value` attribute. When the filter contains compound conditions (AND/OR), `condition_tree` is a branch node that doesn't respond to `.value`, so the code throws `NoMethodError: undefined method 'value'`. Consider checking that `condition_tree` responds to `value` before accessing it, or handle the compound case explicitly.

Evidence trail:
packages/forest_admin_rails/lib/forest_admin_rails/plugins/active_storage.rb line 97: `record_id = context.filter&.condition_tree&.value`
packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_leaf.rb line 13: `attr_reader :field, :operator, :value`
packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree_branch.rb line 7: `attr_reader :aggregator, :conditions` (no `value` attribute)
packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/nodes/condition_tree.rb: base class does not define `value` method

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
{}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 6): handle_write [qlty:function-complexity]

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 7): find_model_class [qlty:function-complexity]

end
end
end
end
Original file line number Diff line number Diff line change
@@ -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
Loading