Skip to content

Commit

Permalink
Support client-side dynamic scoping
Browse files Browse the repository at this point in the history
Closes #2934
  • Loading branch information
mshibuya committed Aug 19, 2023
1 parent 6ab7bd3 commit 12715f2
Show file tree
Hide file tree
Showing 11 changed files with 238 additions and 12 deletions.
1 change: 1 addition & 0 deletions app/assets/javascripts/rails_admin/application.js.erb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
//= require 'rails_admin/popper'
//= require 'rails_admin/bootstrap'

//= require 'rails_admin/abstract-select'
//= require 'rails_admin/filter-box'
//= require 'rails_admin/filtering-multiselect'
//= require 'rails_admin/filtering-select'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
xhr: xhr,
:'edit-url' => (field.inline_edit && authorized?(:edit, config.abstract_model) ? edit_path(model_name: config.abstract_model.to_param, id: '__ID__') : ''),
remote_source: index_path(config.abstract_model, source_object_id: form.object.id, source_abstract_model: source_abstract_model.to_param, associated_collection: field.name, current_action: current_action, compact: true),
scopeBy: field.dynamic_scope_relationships,
sortable: !!field.orderable,
removable: !!field.removable,
cacheAll: !!field.associated_collection_cache_all,
Expand Down
3 changes: 2 additions & 1 deletion app/views/rails_admin/main/_form_filtering_select.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@

js_data = {
xhr: xhr,
remote_source: index_path(config.abstract_model.to_param, source_object_id: form.object.id, source_abstract_model: source_abstract_model.to_param, associated_collection: field.name, current_action: current_action, compact: true)
remote_source: index_path(config.abstract_model.to_param, source_object_id: form.object.id, source_abstract_model: source_abstract_model.to_param, associated_collection: field.name, current_action: current_action, compact: true),
scopeBy: field.dynamic_scope_relationships
}
%>

Expand Down
25 changes: 24 additions & 1 deletion lib/rails_admin/config/fields/association.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,30 @@ def method_name
# preload entire associated collection (per associated_collection_scope) on load
# Be sure to set limit in associated_collection_scope if set is large
register_instance_option :associated_collection_cache_all do
@associated_collection_cache_all ||= (associated_model_config.abstract_model.count < associated_model_limit)
@associated_collection_cache_all ||= dynamically_scope_by.blank? && (associated_model_config.abstract_model.count < associated_model_limit)
end

# client-side dynamic scoping
register_instance_option :dynamically_scope_by do
nil
end

# parses #dynamically_scope_by and returns a Hash in the form of
# {[form field name in this model]: [field name in the associated model]}
def dynamic_scope_relationships
@dynamic_scope_relationships ||=
Array.wrap(dynamically_scope_by).flat_map do |field|
field.is_a?(Hash) ? field.to_a : [[field, field]]
end.map do |field_name, target_name| # rubocop:disable Style/MultilineBlockChain
field = section.fields.detect { |f| f.name == field_name }
raise "Field '#{field_name}' was given for #dynamically_scope_by but not found in '#{abstract_model.model_name}'" unless field

target_field = associated_model_config.list.fields.detect { |f| f.name == target_name }
raise "Field '#{field_name}' was given for #dynamically_scope_by but not found in '#{associated_model_config.abstract_model.model_name}'" unless target_field
raise "Field '#{field_name}' in '#{associated_model_config.abstract_model.model_name}' can't be used for dynamic scoping because it's not filterable" unless target_field.filterable

[field.method_name, target_name]
end.to_h
end

# determines whether association's elements can be removed
Expand Down
37 changes: 37 additions & 0 deletions spec/integration/widgets/filtering_multi_select_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,41 @@
is_expected.to have_content 'New Team'
expect(all(:css, 'input.ra-multiselect-search').count).to eq 1
end

describe 'dynamic scoping' do
let!(:team) { FactoryBot.create :team, division: FactoryBot.create(:division) }
let(:division) { FactoryBot.create(:division) }
let!(:teams) { ['Los Angeles Dodgers', 'Texas Rangers'].map { |name| FactoryBot.create :team, name: name, division: division } }
before do
RailsAdmin.config Team do
field :name
field :division
end
RailsAdmin.config Fan do
field :division, :enum do
enum { Division.pluck(:name, CI_ORM == :active_record ? :custom_id : :id).to_h }
def value
nil
end

def parse_input(params)
params.delete :division
end
end
field :teams do
dynamically_scope_by :division
end
end
visit new_path(model_name: 'fan')
end

it 'changes selection candidates based on value of the specified field' do
expect(all('#fan_team_ids option', visible: false).map(&:value).filter(&:present?)).to be_empty
select division.name, from: 'Division', visible: false
find('input.ra-multiselect-search').set('e')
page.execute_script("document.querySelector('input.ra-multiselect-search').dispatchEvent(new KeyboardEvent('keydown'))")
is_expected.to have_content 'Dodgers'
expect(all('.ra-multiselect-collection option').map(&:text)).to match_array ['Los Angeles Dodgers', 'Texas Rangers']
end
end
end
56 changes: 54 additions & 2 deletions spec/integration/widgets/filtering_select_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
before do
RailsAdmin.config Player do
field :team
field :number
end
end

Expand Down Expand Up @@ -40,7 +41,6 @@
find('input.ra-filtering-select-input').set('Tex')
page.execute_script("document.querySelector('input.ra-filtering-select-input').dispatchEvent(new KeyboardEvent('keydown'))")
is_expected.to have_selector('ul.ui-autocomplete li.ui-menu-item a')
expect(page).to have_selector('ul.ui-autocomplete li.ui-menu-item a')
page.execute_script %{[...document.querySelectorAll('ul.ui-autocomplete li.ui-menu-item')].find(e => e.innerText.includes("Texas Rangers")).click()}
expect(find('#player_team_id', visible: false).value).to eq teams[1].id.to_s
end
Expand All @@ -53,7 +53,6 @@
find('input.ra-filtering-select-input').set('Tex')
page.execute_script("document.querySelector('input.ra-filtering-select-input').dispatchEvent(new KeyboardEvent('keydown'))")
is_expected.to have_selector('ul.ui-autocomplete li.ui-menu-item a')
expect(page).to have_selector('ul.ui-autocomplete li.ui-menu-item a')
page.execute_script %{[...document.querySelectorAll('ul.ui-autocomplete li.ui-menu-item')].find(e => e.innerText.includes("Texas Rangers")).click()}
expect(find('#player_team_id', visible: false).value).to eq teams[1].id.to_s
end
Expand Down Expand Up @@ -133,4 +132,57 @@
expect(all(:css, 'ul.ui-autocomplete li.ui-menu-item a').map(&:text)).to eq ['Cincinnati Reds']
end
end

describe 'dynamic scoping' do
let!(:players) { FactoryBot.create_list :player, 2, team: teams[1] }
let!(:freelancer) { FactoryBot.create :player, team: nil }

context 'with single field' do
before do
player
RailsAdmin.config Draft do
field :team
field :player do
dynamically_scope_by :team
end
end
visit new_path(model_name: 'draft')
end

it 'changes selection candidates based on value of the specified field' do
expect(all('#draft_player_id option', visible: false).map(&:value).filter(&:present?)).to be_empty
find('[data-input-for="draft_team_id"] input.ra-filtering-select-input').set('Tex')
page.execute_script(%{document.querySelector('[data-input-for="draft_team_id"] input.ra-filtering-select-input').dispatchEvent(new KeyboardEvent('keydown'))})
is_expected.to have_selector('ul.ui-autocomplete li.ui-menu-item a')
page.execute_script %{[...document.querySelectorAll('ul.ui-autocomplete li.ui-menu-item')].find(e => e.innerText.includes("Texas Rangers")).click()}
within('[data-input-for="draft_player_id"].filtering-select') { find('.dropdown-toggle').click }
expect(all(:css, 'ul.ui-autocomplete li.ui-menu-item a').map(&:text)).to match_array players.map(&:name)
end

it 'allows filtering by blank value' do
within('[data-input-for="draft_player_id"].filtering-select') { find('.dropdown-toggle').click }
expect(all(:css, 'ul.ui-autocomplete li.ui-menu-item a').map(&:text)).to match_array [freelancer.name]
end
end

context 'with multiple fields' do
before do
player
RailsAdmin.config Draft do
field :team
field :player do
dynamically_scope_by [:team, {round: :number}]
end
field :round
end
visit new_path(model_name: 'draft', draft: {team_id: teams[1].id})
end

it 'changes selection candidates based on value of the specified fields' do
fill_in 'draft[round]', with: players[1].number
within('[data-input-for="draft_player_id"].filtering-select') { find('.dropdown-toggle').click }
expect(all(:css, 'ul.ui-autocomplete li.ui-menu-item a').map(&:text)).to match_array [players[1].name]
end
end
end
end
85 changes: 85 additions & 0 deletions spec/rails_admin/config/fields/association_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,91 @@
end
end

describe '#dynamic_scope_relationships' do
let(:player) { FactoryBot.create(:player, team: FactoryBot.create(:team)) }
let(:field) { RailsAdmin.config('Draft').fields.detect { |f| f.name == :player } }

it 'returns the relationship of fields in this model and in the associated model' do
RailsAdmin.config Draft do
field :team
field :player do
dynamically_scope_by :team
end
end
expect(field.dynamic_scope_relationships).to eq({team_id: :team})
end

it 'accepts Array' do
RailsAdmin.config Draft do
field :team
field :notes
field :player do
dynamically_scope_by %i[team notes]
end
end
expect(field.dynamic_scope_relationships).to eq({team_id: :team, notes: :notes})
end

it 'accepts Hash' do
RailsAdmin.config Draft do
field :round
field :player do
dynamically_scope_by({round: :number})
end
end
expect(field.dynamic_scope_relationships).to eq({round: :number})
end

it 'accepts mixture of Array and Hash' do
RailsAdmin.config Draft do
field :team
field :round
field :player do
dynamically_scope_by [:team, {round: :number}]
end
end
expect(field.dynamic_scope_relationships).to eq({team_id: :team, round: :number})
end

it 'raises error if the field does not exist in this model' do
RailsAdmin.config Draft do
field :player do
dynamically_scope_by :team
end
end
expect { field.dynamic_scope_relationships }.to raise_error "Field 'team' was given for #dynamically_scope_by but not found in 'Draft'"
end

it 'raises error if the field does not exist in the associated model' do
RailsAdmin.config Player do
field :name
end
RailsAdmin.config Draft do
field :team
field :player do
dynamically_scope_by :team
end
end
expect { field.dynamic_scope_relationships }.to raise_error "Field 'team' was given for #dynamically_scope_by but not found in 'Player'"
end

it 'raises error if the target field is not filterable' do
RailsAdmin.config Player do
field :name
field :team do
filterable false
end
end
RailsAdmin.config Draft do
field :team
field :player do
dynamically_scope_by :team
end
end
expect { field.dynamic_scope_relationships }.to raise_error "Field 'team' in 'Player' can't be used for dynamic scoping because it's not filterable"
end
end

describe '#removable?', active_record: true do
context 'with non-nullable foreign key' do
let(:field) { RailsAdmin.config('FieldTest').fields.detect { |f| f.name == :nested_field_tests } }
Expand Down
30 changes: 30 additions & 0 deletions src/rails_admin/abstract-select.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import jQuery from "jquery";
import "jquery-ui/ui/widget.js";

(function ($) {
"use strict";

$.widget("ra.abstractSelect", {
options: {
createQuery: function (query) {
if ($.isEmptyObject(this.scopeBy)) {
return { query: query };
} else {
const filterQuery = {};
for (var field in this.scopeBy) {
const targetField = this.scopeBy[field];
const targetValue = $(`[name$="[${field}]"]`).val();
if (!filterQuery[targetField]) {
filterQuery[targetField] = [];
}
filterQuery[targetField].push(
targetValue ? { o: "is", v: targetValue } : { o: "_blank" }
);
}
return { query: query, f: filterQuery };
}
},
scopeBy: {},
},
});
})(jQuery);
1 change: 1 addition & 0 deletions src/rails_admin/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import "jquery-ui/ui/widget.js";
import "jquery-ui/ui/widgets/menu.js";
import "jquery-ui/ui/widgets/mouse.js";

import "./abstract-select";
import "./filter-box";
import "./filtering-multiselect";
import "./filtering-select";
Expand Down
6 changes: 2 additions & 4 deletions src/rails_admin/filtering-multiselect.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import jQuery from "jquery";
import "jquery-ui/ui/widget.js";
import I18n from "./i18n";

(function ($) {
$.widget("ra.filteringMultiselect", {
$.widget("ra.filteringMultiselect", $.ra.abstractSelect, {
_cache: {},
options: {
createQuery: function (query) {
return { query: query };
},
sortable: false,
removable: true,
regional: {
Expand Down
5 changes: 1 addition & 4 deletions src/rails_admin/filtering-select.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,8 @@ import I18n from "./i18n";
(function ($) {
"use strict";

$.widget("ra.filteringSelect", {
$.widget("ra.filteringSelect", $.ra.abstractSelect, {
options: {
createQuery: function (query) {
return { query: query };
},
minLength: 0,
searchDelay: 200,
remote_source: null,
Expand Down

0 comments on commit 12715f2

Please sign in to comment.