-
Notifications
You must be signed in to change notification settings - Fork 1
feat(rails): support active storage attachments #283
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
| @@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| end | ||
|
|
||
| def handle_write(value, context, model_class, attachment_name) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| record_id = context.filter&.condition_tree&.value | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low Line 97 calls 🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
| 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 | ||
| {} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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 |
There was a problem hiding this comment.
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]