From c1ff3699ab6fc5f9341ff9137e6c307babc30f85 Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Thu, 21 May 2026 15:52:47 +0200 Subject: [PATCH 1/2] fix(rpc): handle properly action form --- .../decorators/action/base_action.rb | 8 +++- .../collection_spec.rb | 36 +++++++++++++++ .../lib/forest_admin_rpc_agent/agent.rb | 21 ++++++++- .../lib/forest_admin_rpc_agent/agent_spec.rb | 44 ++++++++++++++++++- .../routes/action_execute_spec.rb | 5 ++- 5 files changed, 109 insertions(+), 5 deletions(-) diff --git a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/base_action.rb b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/base_action.rb index c0c6ded7c..663241ea4 100644 --- a/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/base_action.rb +++ b/packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/base_action.rb @@ -4,15 +4,18 @@ module Action class BaseAction attr_reader :scope, :form, :is_generate_file, :description, :submit_button_label, :execute, :static_form - def initialize(scope:, form: nil, is_generate_file: false, description: nil, submit_button_label: nil, &execute) + # rubocop:disable Metrics/ParameterLists + def initialize(scope:, form: nil, is_generate_file: false, description: nil, + submit_button_label: nil, static_form: false, &execute) @scope = scope @form = form @is_generate_file = is_generate_file @description = description @submit_button_label = submit_button_label @execute = execute - @static_form = false + @static_form = static_form end + # rubocop:enable Metrics/ParameterLists def self.from_plain_object(action) new( @@ -21,6 +24,7 @@ def self.from_plain_object(action) is_generate_file: action[:is_generate_file], description: action[:description], submit_button_label: action[:submit_button_label], + static_form: action[:static_form], &action[:execute] ) end diff --git a/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/collection_spec.rb b/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/collection_spec.rb index 83b3d05af..84cf3257e 100644 --- a/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/collection_spec.rb +++ b/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/collection_spec.rb @@ -24,6 +24,42 @@ module ForestAdminDatasourceRpc it 'uses the datasource shared RPC client' do expect(collection.instance_variable_get(:@client)).to eq(datasource.shared_rpc_client) end + + context 'when the schema carries action static_form values' do + let(:actions_introspection) do + { + charts: [], + rpc_relations: [], + collections: [ + { + name: 'Files', + countable: false, + searchable: false, + charts: [], + segments: [], + fields: { + id: { + column_type: 'Number', filter_operators: [], is_primary_key: true, + is_read_only: false, is_sortable: true, default_value: nil, + enum_values: [], validation: [], type: 'Column' + } + }, + actions: { + 'static_action': { scope: 'global', static_form: true }, + 'dynamic_action': { scope: 'global', static_form: false } + } + } + ] + } + end + let(:actions_datasource) { Datasource.new({ uri: 'http://localhost' }, actions_introspection) } + + it 'preserves :static_form from the wire instead of recomputing it against an empty form' do + schema = actions_datasource.get_collection('Files').schema[:actions] + expect(schema['static_action'].static_form).to be(true) + expect(schema['dynamic_action'].static_form).to be(false) + end + end end context 'when call list' do diff --git a/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/agent.rb b/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/agent.rb index 893cddac0..246cdeb49 100644 --- a/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/agent.rb +++ b/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/agent.rb @@ -109,7 +109,11 @@ def build_rpc_schema_from_datasource(datasource) end # Normal collection → include in schema - collections << collection.schema.merge({ name: collection.name, fields: fields }) + collections << collection.schema.merge( + name: collection.name, + fields: fields, + actions: serialize_actions(collection.schema[:actions]) + ) end rpc_relations[collection.name] = relations unless relations.empty? @@ -131,6 +135,21 @@ def build_rpc_schema_from_datasource(datasource) schema end + # Only expose the fields needed by an RPC consumer: scope, is_generate_file, static_form, + # description, submit_button_label. Drops `form` (computed via /action-form) and `execute` + # (server-side callback) — neither belongs on the wire. + def serialize_actions(actions) + (actions || {}).transform_values do |action| + { + scope: action.scope, + is_generate_file: action.is_generate_file, + static_form: action.static_form, + description: action.description, + submit_button_label: action.submit_button_label + } + end + end + def relation_targets_rpc_collection?(relation) if relation.type == 'PolymorphicManyToOne' relation.foreign_collections.any? { |fc| @rpc_collections.include?(fc) } diff --git a/packages/forest_admin_rpc_agent/spec/lib/forest_admin_rpc_agent/agent_spec.rb b/packages/forest_admin_rpc_agent/spec/lib/forest_admin_rpc_agent/agent_spec.rb index 24681715d..02d5e694e 100644 --- a/packages/forest_admin_rpc_agent/spec/lib/forest_admin_rpc_agent/agent_spec.rb +++ b/packages/forest_admin_rpc_agent/spec/lib/forest_admin_rpc_agent/agent_spec.rb @@ -128,10 +128,52 @@ module ForestAdminRpcAgent parsed = JSON.parse(written_content, symbolize_names: true) # RPC schema format includes collections with full schemas and native_query_connections - expect(parsed[:collections]).to eq([{ fields: {}, name: 'Test' }]) + expect(parsed[:collections]).to eq([{ fields: {}, name: 'Test', actions: {} }]) expect(parsed[:native_query_connections]).to eq([{ name: 'main' }]) end + it 'exposes only the RPC-relevant action fields on the wire' do + allow(ForestAdminRpcAgent::Facades::Container).to receive(:cache) do |key| + { skip_schema_update: false, schema_path: '/tmp/test-schema.json', is_production: false }[key] + end + + action = instance_double( + ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction, + scope: 'Global', + is_generate_file: true, + static_form: false, + description: 'Download a report', + submit_button_label: 'Go', + form: [{ id: 'should_not_leak' }], + execute: -> {} + ) + test_collection = instance_double(ForestAdminDatasourceToolkit::Collection) + allow(test_collection).to receive_messages( + name: 'Files', + schema: { fields: {}, actions: { 'download' => action } } + ) + allow(datasource).to receive_messages( + collections: { 'Files' => test_collection }, + live_query_connections: {} + ) + + written_content = nil + allow(File).to receive(:write) { |_path, content| written_content = content } + + instance.send_schema + + parsed = JSON.parse(written_content, symbolize_names: true) + expect(parsed[:collections][0][:actions]).to eq( + download: { + scope: 'Global', + is_generate_file: true, + static_form: false, + description: 'Download a report', + submit_button_label: 'Go' + } + ) + end + it 'caches the schema with an etag' do allow(ForestAdminRpcAgent::Facades::Container).to receive(:cache) do |key| { skip_schema_update: false, schema_path: '/tmp/test-schema.json', is_production: false }[key] diff --git a/packages/forest_admin_rpc_agent/spec/lib/forest_admin_rpc_agent/routes/action_execute_spec.rb b/packages/forest_admin_rpc_agent/spec/lib/forest_admin_rpc_agent/routes/action_execute_spec.rb index 17753f4af..c878aea81 100644 --- a/packages/forest_admin_rpc_agent/spec/lib/forest_admin_rpc_agent/routes/action_execute_spec.rb +++ b/packages/forest_admin_rpc_agent/spec/lib/forest_admin_rpc_agent/routes/action_execute_spec.rb @@ -23,7 +23,10 @@ module Routes end let(:args) { { params: params } } let(:expected_response) { { success: true }.to_json } - let(:response) { instance_double(Faraday::Response, success?: true, body: expected_response, status: 200) } + let(:response) do + instance_double(Faraday::Response, success?: true, body: expected_response, status: 200, + headers: {}) + end let(:faraday_connection) { instance_double(Faraday::Connection) } before do From aedfadb99e84d928a6b538168b1a05aa5199dc9a Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Thu, 21 May 2026 16:01:27 +0200 Subject: [PATCH 2/2] ci: lint --- .../collection_spec.rb | 4 +- .../lib/forest_admin_rpc_agent/agent.rb | 65 ++++++++++--------- 2 files changed, 38 insertions(+), 31 deletions(-) diff --git a/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/collection_spec.rb b/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/collection_spec.rb index 84cf3257e..0d307ec67 100644 --- a/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/collection_spec.rb +++ b/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/collection_spec.rb @@ -45,8 +45,8 @@ module ForestAdminDatasourceRpc } }, actions: { - 'static_action': { scope: 'global', static_form: true }, - 'dynamic_action': { scope: 'global', static_form: false } + static_action: { scope: 'global', static_form: true }, + dynamic_action: { scope: 'global', static_form: false } } } ] diff --git a/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/agent.rb b/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/agent.rb index 246cdeb49..5a672ddff 100644 --- a/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/agent.rb +++ b/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/agent.rb @@ -84,36 +84,9 @@ def build_rpc_schema_from_datasource(datasource) relations = {} if @rpc_collections.include?(collection.name) - # RPC collection → extract relations to non-RPC collections - collection.schema[:fields].each do |field_name, field| - next if field.type == 'Column' - next if relation_targets_rpc_collection?(field) - - relations[field_name] = field - end + extract_rpc_collection_relations(collection, relations) else - fields = {} - - collection.schema[:fields].each do |field_name, field| - if field.type != 'Column' && relation_targets_rpc_collection?(field) - relations[field_name] = field - else - if field.type == 'Column' - field.filter_operators = ForestAdminAgent::Utils::Schema::FrontendFilterable.sort_operators( - field.filter_operators - ) - end - - fields[field_name] = field - end - end - - # Normal collection → include in schema - collections << collection.schema.merge( - name: collection.name, - fields: fields, - actions: serialize_actions(collection.schema[:actions]) - ) + collections << build_normal_collection_payload(collection, relations) end rpc_relations[collection.name] = relations unless relations.empty? @@ -135,6 +108,40 @@ def build_rpc_schema_from_datasource(datasource) schema end + # RPC collection → extract relations targeting non-RPC collections. + def extract_rpc_collection_relations(collection, relations) + collection.schema[:fields].each do |field_name, field| + next if field.type == 'Column' + next if relation_targets_rpc_collection?(field) + + relations[field_name] = field + end + end + + # Normal (non-RPC) collection → split fields between local schema and cross-RPC relations. + def build_normal_collection_payload(collection, relations) + fields = {} + + collection.schema[:fields].each do |field_name, field| + if field.type != 'Column' && relation_targets_rpc_collection?(field) + relations[field_name] = field + else + if field.type == 'Column' + field.filter_operators = ForestAdminAgent::Utils::Schema::FrontendFilterable.sort_operators( + field.filter_operators + ) + end + fields[field_name] = field + end + end + + collection.schema.merge( + name: collection.name, + fields: fields, + actions: serialize_actions(collection.schema[:actions]) + ) + end + # Only expose the fields needed by an RPC consumer: scope, is_generate_file, static_form, # description, submit_button_label. Drops `form` (computed via /action-form) and `execute` # (server-side callback) — neither belongs on the wire.