activeadmin-searchable_select 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/.rspec +1 -0
  4. data/.rubocop.yml +10 -0
  5. data/.travis.yml +16 -0
  6. data/.yardopts +2 -0
  7. data/Appraisals +15 -0
  8. data/CHANGELOG.md +7 -0
  9. data/Gemfile +3 -0
  10. data/LICENSE.txt +25 -0
  11. data/README.md +247 -0
  12. data/Rakefile +6 -0
  13. data/activeadmin-searchable_select.gemspec +37 -0
  14. data/app/assets/javascripts/active_admin/searchable_select.js.coffee +3 -0
  15. data/app/assets/javascripts/active_admin/searchable_select/init.js.coffee +29 -0
  16. data/app/assets/stylesheets/active_admin/searchable_select.scss +5 -0
  17. data/bin/rspec +17 -0
  18. data/gemfiles/rails_4.2_active_admin_1.0.0.pre4.gemfile +9 -0
  19. data/gemfiles/rails_5.1_active_admin_1.0.gemfile +8 -0
  20. data/gemfiles/rails_5.1_active_admin_1.1.gemfile +8 -0
  21. data/lib/activeadmin-searchable_select.rb +6 -0
  22. data/lib/activeadmin/inputs/filters/searchable_select_input.rb +13 -0
  23. data/lib/activeadmin/inputs/searchable_select_input.rb +11 -0
  24. data/lib/activeadmin/searchable_select.rb +20 -0
  25. data/lib/activeadmin/searchable_select/engine.rb +11 -0
  26. data/lib/activeadmin/searchable_select/option_collection.rb +103 -0
  27. data/lib/activeadmin/searchable_select/resource_dsl_extension.rb +39 -0
  28. data/lib/activeadmin/searchable_select/resource_extension.rb +10 -0
  29. data/lib/activeadmin/searchable_select/select_input_extension.rb +130 -0
  30. data/lib/activeadmin/searchable_select/version.rb +5 -0
  31. data/spec/features/ajax_params_spec.rb +53 -0
  32. data/spec/features/end_to_end_spec.rb +83 -0
  33. data/spec/features/filter_input_spec.rb +191 -0
  34. data/spec/features/form_input_spec.rb +178 -0
  35. data/spec/features/inline_ajax_setting_spec.rb +41 -0
  36. data/spec/features/input_errors_spec.rb +55 -0
  37. data/spec/features/options_dsl_spec.rb +248 -0
  38. data/spec/internal/app/assets/javascripts/active_admin.js +2 -0
  39. data/spec/internal/app/assets/stylesheets/active_admin.scss +2 -0
  40. data/spec/internal/app/controllers/application_controller.rb +5 -0
  41. data/spec/internal/config/database.yml +3 -0
  42. data/spec/internal/config/initializers/assets.rb +3 -0
  43. data/spec/internal/config/routes.rb +3 -0
  44. data/spec/internal/db/schema.rb +26 -0
  45. data/spec/internal/log/.gitignore +1 -0
  46. data/spec/internal/public/favicon.ico +0 -0
  47. data/spec/rails_helper.rb +13 -0
  48. data/spec/spec_helper.rb +96 -0
  49. data/spec/support/active_admin_helpers.rb +9 -0
  50. data/spec/support/capybara.rb +8 -0
  51. data/spec/support/models.rb +21 -0
  52. data/spec/support/pluck_polyfill.rb +12 -0
  53. data/spec/support/reset_settings.rb +5 -0
  54. metadata +311 -0
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ #
4
+ # This file was generated by Bundler.
5
+ #
6
+ # The application 'rspec' is installed as part of a gem, and
7
+ # this file is here to facilitate running it.
8
+ #
9
+
10
+ require "pathname"
11
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
12
+ Pathname.new(__FILE__).realpath)
13
+
14
+ require "rubygems"
15
+ require "bundler/setup"
16
+
17
+ load Gem.bin_path("rspec-core", "rspec")
@@ -0,0 +1,9 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "~> 4.2"
6
+ gem "activeadmin", "1.0.0.pre4"
7
+ gem "jquery-ui-rails", "~> 5.0"
8
+
9
+ gemspec path: "../"
@@ -0,0 +1,8 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "~> 5.1"
6
+ gem "activeadmin", "~> 1.0"
7
+
8
+ gemspec path: "../"
@@ -0,0 +1,8 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "~> 5.1"
6
+ gem "activeadmin", "~> 1.1"
7
+
8
+ gemspec path: "../"
@@ -0,0 +1,6 @@
1
+ # rubocop:disable Style/FileName
2
+
3
+ require 'activeadmin/searchable_select'
4
+
5
+ require 'activeadmin/inputs/filters/searchable_select_input'
6
+ require 'activeadmin/inputs/searchable_select_input'
@@ -0,0 +1,13 @@
1
+ module ActiveAdmin
2
+ module Inputs
3
+ module Filters
4
+ # Searchable select input type for ActiveAdmin filters.
5
+ #
6
+ # @see ActiveAdmin::SearchableSelect::SelectInputExtension
7
+ # SelectInputExtension for list of available options.
8
+ class SearchableSelectInput < SelectInput
9
+ include SearchableSelect::SelectInputExtension
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,11 @@
1
+ module ActiveAdmin
2
+ module Inputs
3
+ # Searchable select input type for ActiveAdmin filters.
4
+ #
5
+ # @see ActiveAdmin::SearchableSelect::SelectInputExtension
6
+ # SelectInputExtension for list of available options.
7
+ class SearchableSelectInput < Formtastic::Inputs::SelectInput
8
+ include SearchableSelect::SelectInputExtension
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,20 @@
1
+ require 'activeadmin/searchable_select/engine'
2
+ require 'activeadmin/searchable_select/option_collection'
3
+ require 'activeadmin/searchable_select/resource_extension'
4
+ require 'activeadmin/searchable_select/resource_dsl_extension'
5
+ require 'activeadmin/searchable_select/select_input_extension'
6
+ require 'activeadmin/searchable_select/version'
7
+
8
+ ActiveAdmin::Resource.send :include, ActiveAdmin::SearchableSelect::ResourceExtension
9
+ ActiveAdmin::ResourceDSL.send :include, ActiveAdmin::SearchableSelect::ResourceDSLExtension
10
+
11
+ module ActiveAdmin
12
+ # Global settings for searchable selects
13
+ module SearchableSelect
14
+ # Statically render all options into searchable selects with
15
+ # `ajax` option set to true. This can be used to ease ui driven
16
+ # integration testing.
17
+ mattr_accessor :inline_ajax_options
18
+ self.inline_ajax_options = false
19
+ end
20
+ end
@@ -0,0 +1,11 @@
1
+ require 'active_admin'
2
+ require 'select2-rails'
3
+
4
+ module ActiveAdmin
5
+ module SearchableSelect
6
+ # @api private
7
+ class Engine < ::Rails::Engine
8
+ engine_name 'activeadmin_searchable_select'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,103 @@
1
+ module ActiveAdmin
2
+ module SearchableSelect
3
+ # @api private
4
+ class OptionCollection
5
+ def initialize(name, options)
6
+ @name = name
7
+ @scope = extract_scope_option(options)
8
+ @display_text = extract_display_text_option(options)
9
+ @filter = extract_filter_option(options)
10
+ @per_page = options.fetch(:per_page, 10)
11
+ end
12
+
13
+ def scope(template, params)
14
+ case @scope
15
+ when Proc
16
+ if @scope.arity.zero?
17
+ template.instance_exec(&@scope)
18
+ else
19
+ template.instance_exec(params, &@scope)
20
+ end
21
+ else
22
+ @scope
23
+ end
24
+ end
25
+
26
+ def display_text(record)
27
+ @display_text.call(record)
28
+ end
29
+
30
+ def collection_action_name
31
+ "#{@name}_options"
32
+ end
33
+
34
+ def as_json(template, params)
35
+ records, more = fetch_records(template, params)
36
+
37
+ results = records.map do |record|
38
+ {
39
+ id: record.id,
40
+ text: display_text(record)
41
+ }
42
+ end
43
+
44
+ { results: results, pagination: { more: more } }
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :per_page
50
+
51
+ def fetch_records(template, params)
52
+ paginate(filter(scope(template, params), params[:term]),
53
+ params[:page])
54
+ end
55
+
56
+ def filter(scope, term)
57
+ term ? @filter.call(term, scope) : scope
58
+ end
59
+
60
+ def paginate(scope, page_index)
61
+ page_index = page_index.to_i
62
+
63
+ records = scope.limit(per_page + 1).offset(page_index * per_page).to_a
64
+
65
+ [
66
+ records.slice(0, per_page),
67
+ records.size > per_page
68
+ ]
69
+ end
70
+
71
+ def extract_scope_option(options)
72
+ options.fetch(:scope) do
73
+ raise('Missing option: scope. ' \
74
+ 'Pass the collection of items to render options for.')
75
+ end
76
+ end
77
+
78
+ def extract_display_text_option(options)
79
+ options.fetch(:display_text) do
80
+ text_attribute = options.fetch(:text_attribute) do
81
+ raise('Missing option: display_text or text_attribute. ' \
82
+ 'Either pass a proc to determine the display text for a record ' \
83
+ 'or set the text_attribute option.')
84
+ end
85
+
86
+ ->(record) { record.send(text_attribute) }
87
+ end
88
+ end
89
+
90
+ def extract_filter_option(options)
91
+ options.fetch(:filter) do
92
+ text_attribute = options.fetch(:text_attribute) do
93
+ raise('Missing option: filter or text_attribute. ' \
94
+ 'Either pass a proc which filters the scope according to a given ' \
95
+ 'or set the text_attribute option to apply a default Ransack filter.')
96
+ end
97
+
98
+ ->(term, scope) { scope.ransack("#{text_attribute}_cont" => term).result }
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,39 @@
1
+ module ActiveAdmin
2
+ module SearchableSelect
3
+ # Mixin for ActiveAdmin resource DSL
4
+ module ResourceDSLExtension
5
+ # Define a collection action to serve options JSON data for
6
+ # searchable selects.
7
+ #
8
+ # @param scope [ActiveRecord::Relation, Proc] Either a
9
+ # collection of records to create options for or a proc
10
+ # returning such a collection. Procs are evaluated in the
11
+ # context of the collection action defined by this
12
+ # method. Procs can optionally take a single `params` argument
13
+ # containing data defined under the `params` key of the
14
+ # input's `ajax` option. Required.
15
+ #
16
+ # @param text_attribute [Symbol] Name of attribute to use as
17
+ # display name and to filter by search term.
18
+ #
19
+ # @param display_text [Proc] Takes the record as
20
+ # parameter. Required if `text_attribute` is not present.
21
+ #
22
+ # @param filter [Proc] Takes the search term and an Active
23
+ # Record scope as parameters and needs to return a scope of
24
+ # filtered records. Required if `text_attribute` is not
25
+ # present.
26
+ #
27
+ # @param name [Symbol] Optional collection name if helper is
28
+ # used multiple times within one resource.
29
+ def searchable_select_options(name: :all, **options)
30
+ option_collection = OptionCollection.new(name, options)
31
+ config.searchable_select_option_collections[name] = option_collection
32
+
33
+ collection_action(option_collection.collection_action_name) do
34
+ render(json: option_collection.as_json(self, params))
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,10 @@
1
+ module ActiveAdmin
2
+ module SearchableSelect
3
+ # @api private
4
+ module ResourceExtension
5
+ def searchable_select_option_collections
6
+ @searchable_select_option_collections ||= {}
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,130 @@
1
+ module ActiveAdmin
2
+ module SearchableSelect
3
+ # Mixin for searchable select inputs.
4
+ #
5
+ # Supports the same options as inputs of type `:select`.
6
+ #
7
+ # Adds support for an `ajax` option to fetch options data from a
8
+ # JSON endpoint. Pass either `true` to use defaults or a hash
9
+ # containing some of the following options:
10
+ #
11
+ # - `resource`: ActiveRecord model class of ActiveAdmin resource
12
+ # which provides the collection action to fetch options
13
+ # from. By default the resource is auto detected via the name
14
+ # of the input attribute.
15
+ #
16
+ # - `collection_name`: Name passed to the
17
+ # `searchable_select_options` method that defines the collection
18
+ # action to fetch options from.
19
+ #
20
+ # - `params`: Hash of query parameters that shall be passed to the
21
+ # options endpoint.
22
+ #
23
+ # If the `ajax` option is present, the `collection` option is
24
+ # ignored.
25
+ module SelectInputExtension
26
+ # @api private
27
+ def input_html_options
28
+ options = super
29
+ options[:class] = [options[:class], 'searchable-select-input'].compact.join(' ')
30
+ options.merge('data-ajax-url' => ajax_url)
31
+ end
32
+
33
+ # @api private
34
+ def collection_from_options
35
+ return super unless options[:ajax]
36
+
37
+ if SearchableSelect.inline_ajax_options
38
+ all_options_collection
39
+ else
40
+ selected_value_collection
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def ajax_url
47
+ return unless options[:ajax]
48
+ template.polymorphic_path([:admin, ajax_resource_class],
49
+ action: option_collection.collection_action_name,
50
+ **ajax_params)
51
+ end
52
+
53
+ def all_options_collection
54
+ option_collection_scope.all.map do |record|
55
+ option_for_record(record)
56
+ end
57
+ end
58
+
59
+ def selected_value_collection
60
+ [selected_value_option].compact
61
+ end
62
+
63
+ def selected_value_option
64
+ option_for_record(selected_record) if selected_record
65
+ end
66
+
67
+ def option_for_record(record)
68
+ [option_collection.display_text(record), record.id]
69
+ end
70
+
71
+ def selected_record
72
+ @selected_record ||=
73
+ selected_value && option_collection_scope.find_by_id(selected_value)
74
+ end
75
+
76
+ def selected_value
77
+ @object.send(input_name) if @object
78
+ end
79
+
80
+ def option_collection_scope
81
+ option_collection.scope(template, ajax_params)
82
+ end
83
+
84
+ def option_collection
85
+ ajax_resource
86
+ .searchable_select_option_collections
87
+ .fetch(ajax_option_collection_name) do
88
+ raise("No option collection named '#{ajax_option_collection_name}' " \
89
+ "defined in '#{ajax_resource_class.name}' admin.")
90
+ end
91
+ end
92
+
93
+ def ajax_resource
94
+ @ajax_resource ||=
95
+ template.active_admin_namespace.resource_for(ajax_resource_class) ||
96
+ raise("No admin found for '#{ajax_resource_class.name}' to fetch " \
97
+ 'options for searchable select input from.')
98
+ end
99
+
100
+ def ajax_resource_class
101
+ ajax_options.fetch(:resource) do
102
+ raise_cannot_auto_detect_resource unless reflection
103
+ reflection.klass
104
+ end
105
+ end
106
+
107
+ def raise_cannot_auto_detect_resource
108
+ raise('Cannot auto detect resource to fetch options for searchable select input from. ' \
109
+ "Explicitly pass class of an ActiveAdmin resource:\n\n" \
110
+ " f.input(:custom_category,\n" \
111
+ " type: :searchable_select,\n" \
112
+ " ajax: {\n" \
113
+ " resource: Category\n" \
114
+ " })\n")
115
+ end
116
+
117
+ def ajax_option_collection_name
118
+ ajax_options.fetch(:collection_name, :all)
119
+ end
120
+
121
+ def ajax_params
122
+ ajax_options.fetch(:params, {})
123
+ end
124
+
125
+ def ajax_options
126
+ options[:ajax] == true ? {} : options[:ajax]
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,5 @@
1
+ module ActiveAdmin
2
+ module SearchableSelect
3
+ VERSION = '1.0.0'.freeze
4
+ end
5
+ end
@@ -0,0 +1,53 @@
1
+ require 'rails_helper'
2
+
3
+ require 'support/models'
4
+ require 'support/capybara'
5
+ require 'support/active_admin_helpers'
6
+
7
+ RSpec.describe 'ajax params', type: :request do
8
+ before(:each) do
9
+ ActiveAdminHelpers.setup do
10
+ ActiveAdmin.register(Category) do
11
+ searchable_select_options(scope: lambda do |params|
12
+ Category.where(created_by_id: params[:created_by])
13
+ end,
14
+ text_attribute: :name)
15
+ end
16
+
17
+ ActiveAdmin.register(Post) do
18
+ form do |f|
19
+ f.input(:category,
20
+ as: :searchable_select,
21
+ ajax: {
22
+ params: {
23
+ created_by: current_user.id
24
+ }
25
+ })
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ it 'passes parameters when rendering selected item' do
32
+ user = User.create
33
+ category = Category.create(name: 'Travel', created_by: user)
34
+ post = Post.create(category: category)
35
+
36
+ ApplicationController.current_user = user
37
+ get "/admin/posts/#{post.id}/edit"
38
+
39
+ expect(response.body).to have_selector('.searchable-select-input option[selected]',
40
+ text: 'Travel')
41
+ end
42
+
43
+ it 'includes parameters in ajax url' do
44
+ user = User.create
45
+
46
+ ApplicationController.current_user = user
47
+ get '/admin/posts/new'
48
+
49
+ url_matcher = "?created_by=#{user.id}"
50
+ expect(response.body).to have_selector('.searchable-select-input' \
51
+ "[data-ajax-url*='#{url_matcher}']")
52
+ end
53
+ end