├── lib ├── admino │ ├── version.rb │ ├── table.rb │ ├── query │ │ ├── scope_presenter.rb │ │ ├── builder.rb │ │ ├── base_presenter.rb │ │ ├── dsl.rb │ │ ├── search_field.rb │ │ ├── filter_group.rb │ │ ├── sorting_presenter.rb │ │ ├── sorting.rb │ │ ├── filter_group_presenter.rb │ │ ├── configuration.rb │ │ └── base.rb │ ├── query.rb │ ├── table │ │ ├── row.rb │ │ ├── head_row.rb │ │ ├── presenter.rb │ │ └── resource_row.rb │ └── action_view_extension.rb └── admino.rb ├── logo.jpg ├── Gemfile ├── TODO.md ├── Rakefile ├── Appraisals ├── gemfiles ├── rails_40.gemfile ├── rails_42.gemfile ├── rails_40.gemfile.lock └── rails_42.gemfile.lock ├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── CHANGELOG.md ├── admino.gemspec ├── spec ├── admino │ ├── query │ │ ├── dsl_spec.rb │ │ ├── search_field_spec.rb │ │ ├── base_presenter_spec.rb │ │ ├── filter_group_spec.rb │ │ ├── sorting_spec.rb │ │ ├── filter_group_presenter_spec.rb │ │ ├── base_spec.rb │ │ └── sorting_presenter_spec.rb │ ├── table │ │ ├── row_spec.rb │ │ ├── head_row_spec.rb │ │ ├── presenter_spec.rb │ │ └── resource_row_spec.rb │ └── integration_spec.rb └── spec_helper.rb ├── CONTRIBUTING.md └── README.md /lib/admino/version.rb: -------------------------------------------------------------------------------- 1 | module Admino 2 | VERSION = "0.0.22" 3 | end 4 | -------------------------------------------------------------------------------- /logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cantierecreativo/admino/HEAD/logo.jpg -------------------------------------------------------------------------------- /lib/admino/table.rb: -------------------------------------------------------------------------------- 1 | require 'admino/table/presenter' 2 | 3 | module Admino 4 | module Table 5 | end 6 | end 7 | 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem 'appraisal' 6 | gem 'coveralls', require: false 7 | 8 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | * Allow validations of input in Query::Base objects 2 | * Support Rails date_select and datetime_select params 3 | * Prepare a demo 4 | 5 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | task :default => :spec 6 | 7 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "rails-40" do 2 | gem "rails", "4.0.0" 3 | gem "simple_form", "3.0.3" 4 | end 5 | 6 | appraise "rails-42" do 7 | gem "rails", "4.2.1" 8 | gem "simple_form", "3.1.0" 9 | end 10 | -------------------------------------------------------------------------------- /gemfiles/rails_40.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "coveralls", :require => false 7 | gem "rails", "4.0.0" 8 | gem "simple_form", "3.0.3" 9 | 10 | gemspec :path => "../" 11 | -------------------------------------------------------------------------------- /gemfiles/rails_42.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "coveralls", :require => false 7 | gem "rails", "4.2.1" 8 | gem "simple_form", "3.1.0" 9 | 10 | gemspec :path => "../" 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | binstubs/ 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp 19 | bin/* 20 | 21 | -------------------------------------------------------------------------------- /lib/admino.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/module/delegation' 2 | 3 | require "admino/version" 4 | require "admino/query" 5 | require "admino/table" 6 | require "admino/action_view_extension" 7 | 8 | module Admino 9 | end 10 | 11 | ActiveSupport.on_load(:action_view) do 12 | include Admino::ActionViewExtension 13 | end 14 | 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | rvm: 4 | - "2.0.0" 5 | - "2.1.1" 6 | - "2.2.0" 7 | - "2.3.0" 8 | 9 | gemfile: 10 | - "gemfiles/rails_40.gemfile" 11 | - "gemfiles/rails_42.gemfile" 12 | 13 | before_install: 14 | - gem install bundler 15 | 16 | install: 17 | - bundle install --jobs=3 --retry=3 && bundle update 18 | 19 | script: "bundle exec rake spec" 20 | -------------------------------------------------------------------------------- /lib/admino/query/scope_presenter.rb: -------------------------------------------------------------------------------- 1 | require 'showcase' 2 | 3 | module Admino 4 | module Query 5 | class ScopePresenter < Showcase::Presenter 6 | def initialize(object, parent, view_context) 7 | super(object, view_context) 8 | @parent = parent 9 | end 10 | 11 | def link(*args) 12 | @parent.scope_link(object, *args) 13 | end 14 | end 15 | end 16 | end 17 | 18 | -------------------------------------------------------------------------------- /lib/admino/query.rb: -------------------------------------------------------------------------------- 1 | require 'admino/query/base' 2 | require 'admino/query/base_presenter' 3 | require 'admino/query/configuration' 4 | require 'admino/query/search_field' 5 | require 'admino/query/filter_group' 6 | require 'admino/query/filter_group_presenter' 7 | require 'admino/query/sorting' 8 | require 'admino/query/sorting_presenter' 9 | require 'admino/query/scope_presenter' 10 | 11 | module Admino 12 | module Query 13 | end 14 | end 15 | 16 | -------------------------------------------------------------------------------- /lib/admino/query/builder.rb: -------------------------------------------------------------------------------- 1 | module Admino 2 | module Query 3 | class Builder 4 | attr_accessor :scope 5 | attr_reader :context 6 | 7 | def initialize(context, scope) 8 | @context = context 9 | @scope = scope 10 | end 11 | 12 | private 13 | 14 | def method_missing(method, *args) 15 | context_method = "#{method}_scope" 16 | if context.respond_to?(context_method) 17 | Builder.new(context, context.send(context_method, scope, *args)) 18 | else 19 | Builder.new(context, scope.send(method, *args)) 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/admino/query/base_presenter.rb: -------------------------------------------------------------------------------- 1 | require 'showcase' 2 | 3 | module Admino 4 | module Query 5 | class BasePresenter < Showcase::Presenter 6 | presents_collection :filter_groups 7 | presents :sorting 8 | 9 | def form(options = {}, &block) 10 | h.form_for( 11 | self, 12 | options.reverse_merge(default_form_options), 13 | &block 14 | ) 15 | end 16 | 17 | def simple_form(options = {}, &block) 18 | h.simple_form_for( 19 | self, 20 | options.reverse_merge(default_form_options), 21 | &block 22 | ) 23 | end 24 | 25 | def default_form_options 26 | { 27 | as: :query, 28 | method: :get, 29 | url: h.request.fullpath 30 | } 31 | end 32 | end 33 | end 34 | end 35 | 36 | -------------------------------------------------------------------------------- /lib/admino/query/dsl.rb: -------------------------------------------------------------------------------- 1 | module Admino 2 | module Query 3 | module Dsl 4 | def config 5 | @config ||= Admino::Query::Configuration.new 6 | end 7 | 8 | def search_field(name, options = {}) 9 | config.add_search_field(name, options) 10 | 11 | define_method name do 12 | search_field_by_name(name).value 13 | end 14 | end 15 | 16 | def filter_by(name, scopes, options = {}) 17 | config.add_filter_group(name, scopes, options) 18 | 19 | define_method name do 20 | filter_group_by_name(name).value.to_s 21 | end 22 | end 23 | 24 | def sorting(*args) 25 | options = args.extract_options! 26 | config.add_sorting_scopes(args, options) 27 | end 28 | 29 | def starting_scope(&block) 30 | config.starting_scope_callable = block 31 | end 32 | 33 | def ending_scope(&block) 34 | config.ending_scope_callable = block 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Stefano Verna 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.0.10 2 | 3 | * Fix bug in action generation of html attributes 4 | 5 | # v0.0.9 6 | 7 | * Allow `include_empty_scope` option in filter groups 8 | 9 | ``` 10 | filter_by :status, [:foo, :bar], include_empty_scope: true 11 | ``` 12 | 13 | # v0.0.8 14 | 15 | * Support a symbol column label. It will use human attribute name: 16 | 17 | ``` 18 | = row.column :truncated_title, :title 19 | ``` 20 | 21 | # v0.0.7 22 | 23 | * `#scope_params` does not change request params 24 | 25 | # v0.0.6 26 | 27 | * Moved the filter group params inside of the `:query` hash 28 | 29 | # v0.0.5 30 | 31 | * Rename Field into SearchField 32 | * Admino::Table::Presenter no longer presents collection by default 33 | 34 | # v0.0.4 35 | 36 | * Rename Group into FilterGroup 37 | * Rename `FilterGroup#available_scopes` into `#scopes` 38 | * Rename `Sorting#available_scopes` into `#scopes` 39 | * Removed nil scope in `FilterGroup` 40 | * Clicking on an active filter scope link will deactivate it 41 | 42 | # v0.0.3 43 | 44 | * Fixed bug in `SortingPresenter` with default scope 45 | 46 | # v0.0.2 47 | 48 | * Support to sortings 49 | 50 | # v0.0.1 51 | 52 | * First release 53 | 54 | -------------------------------------------------------------------------------- /lib/admino/query/search_field.rb: -------------------------------------------------------------------------------- 1 | require 'coercible' 2 | require 'active_support/hash_with_indifferent_access' 3 | require 'active_support/core_ext/hash' 4 | 5 | module Admino 6 | module Query 7 | class SearchField 8 | attr_reader :params 9 | attr_reader :config 10 | 11 | def initialize(config, params) 12 | @config = config 13 | @params = ActiveSupport::HashWithIndifferentAccess.new(params) 14 | end 15 | 16 | def augment_scope(scope) 17 | if present? 18 | scope.send(scope_name, value) 19 | else 20 | scope 21 | end 22 | end 23 | 24 | def value 25 | value = params.fetch(:query, {}).fetch(param_name, nil) 26 | 27 | if config.coerce_to 28 | value = begin 29 | coercer = Coercible::Coercer.new 30 | coercer[value.class].send(config.coerce_to, value) 31 | rescue Coercible::UnsupportedCoercion 32 | nil 33 | end 34 | end 35 | 36 | value || config.default_value 37 | end 38 | 39 | def present? 40 | value.present? 41 | end 42 | 43 | def param_name 44 | config.name 45 | end 46 | 47 | def scope_name 48 | config.name 49 | end 50 | end 51 | end 52 | end 53 | 54 | -------------------------------------------------------------------------------- /lib/admino/table/row.rb: -------------------------------------------------------------------------------- 1 | module Admino 2 | module Table 3 | class Row 4 | attr_reader :view_context 5 | 6 | alias_method :h, :view_context 7 | 8 | def initialize(view_context) 9 | @view_context = view_context 10 | end 11 | 12 | def parse_column_args(args) 13 | html_options = args.extract_options! 14 | 15 | attribute_name = if args.first.is_a?(Symbol) 16 | args.shift 17 | else 18 | nil 19 | end 20 | 21 | label = if args.first.is_a?(String) || args.first.is_a?(Symbol) 22 | args.shift 23 | else 24 | nil 25 | end 26 | 27 | [attribute_name, label, html_options] 28 | end 29 | 30 | def parse_action_args(args) 31 | html_options = args.extract_options! 32 | 33 | action_name = if args.first.is_a?(Symbol) 34 | args.shift 35 | else 36 | nil 37 | end 38 | 39 | url = if args.first.is_a?(String) 40 | args.shift 41 | else 42 | nil 43 | end 44 | 45 | label = args.shift 46 | 47 | [action_name, url, label, html_options] 48 | end 49 | 50 | def to_html 51 | nil 52 | end 53 | end 54 | end 55 | end 56 | 57 | -------------------------------------------------------------------------------- /admino.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'admino/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'admino' 8 | spec.version = Admino::VERSION 9 | spec.authors = ['Stefano Verna'] 10 | spec.email = ['s.verna@cantierecreativo.net'] 11 | spec.description = %q{Make administrative views creation less repetitive} 12 | spec.summary = %q{Make administrative views creation less repetitive} 13 | spec.homepage = 'https://github.com/cantierecreativo/admino' 14 | spec.license = 'MIT' 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ['lib'] 20 | 21 | spec.add_dependency 'showcase', '~> 0.2.5' 22 | spec.add_dependency 'activesupport' 23 | spec.add_dependency 'activemodel' 24 | spec.add_dependency 'coercible' 25 | 26 | spec.add_development_dependency 'bundler', '~> 1.3' 27 | spec.add_development_dependency 'rake' 28 | spec.add_development_dependency 'rspec' 29 | spec.add_development_dependency 'pry' 30 | spec.add_development_dependency 'i18n' 31 | spec.add_development_dependency 'rspec-html-matchers' 32 | spec.add_development_dependency 'actionpack' 33 | spec.add_development_dependency 'simple_form' 34 | end 35 | 36 | -------------------------------------------------------------------------------- /spec/admino/query/dsl_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Admino 4 | module Query 5 | describe Dsl do 6 | let(:config) { TestQuery.config } 7 | let(:instance) { TestQuery.new } 8 | 9 | it 'allows #search_field declaration' do 10 | search_field = config.search_fields.last 11 | expect(search_field.name).to eq :starting_from 12 | expect(search_field.coerce_to).to eq :to_date 13 | end 14 | 15 | it 'allows #filter_by declaration' do 16 | filter_group = config.filter_groups.first 17 | expect(filter_group.name).to eq :bar 18 | expect(filter_group.scopes).to eq [:one, :two] 19 | expect(filter_group.include_empty_scope?).to be_truthy 20 | end 21 | 22 | it 'allows #sortings declaration' do 23 | sorting = config.sorting 24 | expect(sorting.scopes).to eq [:by_title, :by_date] 25 | expect(sorting.default_scope).to eq :by_title 26 | expect(sorting.default_direction).to eq :desc 27 | end 28 | 29 | it 'allows #starting_scope block declaration' do 30 | expect(config.starting_scope_callable.call).to eq 'start' 31 | end 32 | 33 | it 'allows #ending_scope block declaration' do 34 | expect(config.ending_scope_callable.call).to eq 'end' 35 | end 36 | 37 | context 'with a search_field' do 38 | let(:search_field) { double('SearchField', value: 'value') } 39 | 40 | before do 41 | allow(instance).to receive(:search_field_by_name). 42 | with(:foo). 43 | and_return(search_field) 44 | end 45 | 46 | it 'it generates a getter' do 47 | expect(instance.foo).to eq 'value' 48 | end 49 | end 50 | end 51 | end 52 | end 53 | 54 | -------------------------------------------------------------------------------- /lib/admino/query/filter_group.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/hash_with_indifferent_access' 2 | require 'active_support/core_ext/hash' 3 | 4 | module Admino 5 | module Query 6 | class FilterGroup 7 | attr_reader :params 8 | attr_reader :config 9 | attr_reader :query_i18n_key 10 | 11 | def initialize(config, params, query_i18n_key = nil) 12 | @config = config 13 | @params = ActiveSupport::HashWithIndifferentAccess.new(params) 14 | @query_i18n_key = query_i18n_key 15 | end 16 | 17 | def augment_scope(scope) 18 | if active_scope && active_scope != :empty 19 | scope.send(active_scope) 20 | else 21 | scope 22 | end 23 | end 24 | 25 | def active_scope 26 | if value && scopes.include?(value) 27 | value 28 | else 29 | nil 30 | end 31 | end 32 | 33 | def is_scope_active?(scope) 34 | active_scope == scope.to_sym 35 | end 36 | 37 | def value 38 | value = params.fetch(:query, {}).fetch(param_name, default_value) 39 | if value 40 | value.to_sym 41 | else 42 | nil 43 | end 44 | end 45 | 46 | def default_value 47 | if config.default_scope 48 | config.default_scope 49 | elsif config.include_empty_scope? 50 | :empty 51 | else 52 | nil 53 | end 54 | end 55 | 56 | def param_name 57 | config.name 58 | end 59 | 60 | def scopes 61 | @scopes ||= config.scopes.dup.tap do |scopes| 62 | if config.include_empty_scope? 63 | scopes.unshift :empty 64 | end 65 | end 66 | end 67 | 68 | def i18n_key 69 | config.name 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/admino/query/sorting_presenter.rb: -------------------------------------------------------------------------------- 1 | require 'showcase' 2 | require 'active_support/core_ext/object/deep_dup' 3 | 4 | module Admino 5 | module Query 6 | class SortingPresenter < Showcase::Presenter 7 | def scope_link(scope, *args) 8 | options = args.extract_options! 9 | 10 | label = args.first || scope_name(scope) 11 | 12 | desc_class = options.delete(:desc_class) { 'is-desc' } 13 | asc_class = options.delete(:asc_class) { 'is-asc' } 14 | 15 | options = Showcase::Helpers::HtmlOptions.new(options) 16 | 17 | if is_scope_active?(scope) 18 | options.add_class!(ascending? ? asc_class : desc_class) 19 | end 20 | 21 | h.link_to label, scope_path(scope), options.to_h 22 | end 23 | 24 | def scope_path(scope) 25 | h.request.path + "?" + scope_params(scope).to_query 26 | end 27 | 28 | def scopes 29 | @scopes ||= super.map do |scope| 30 | ScopePresenter.new(scope, self, view_context) 31 | end 32 | end 33 | 34 | def scope_params(scope) 35 | params = ActiveSupport::HashWithIndifferentAccess.new( 36 | h.request.query_parameters.deep_dup 37 | ) 38 | 39 | if is_scope_active?(scope) 40 | params.merge!(sorting: scope.to_s, sort_order: ascending? ? 'desc' : 'asc') 41 | elsif default_scope == scope 42 | params.merge!(sorting: scope.to_s, sort_order: default_direction.to_s) 43 | else 44 | params.merge!(sorting: scope.to_s, sort_order: 'asc') 45 | end 46 | 47 | params 48 | end 49 | 50 | def scope_name(scope) 51 | I18n.t( 52 | :"#{query_i18n_key}.#{scope}", 53 | scope: 'query.sorting_scopes', 54 | default: scope.to_s.titleize.capitalize 55 | ) 56 | end 57 | end 58 | end 59 | end 60 | 61 | -------------------------------------------------------------------------------- /lib/admino/query/sorting.rb: -------------------------------------------------------------------------------- 1 | require 'coercible' 2 | require 'active_support/hash_with_indifferent_access' 3 | require 'active_support/core_ext/hash' 4 | 5 | module Admino 6 | module Query 7 | class Sorting 8 | attr_reader :params 9 | attr_reader :config 10 | attr_reader :query_i18n_key 11 | 12 | def initialize(config, params, query_i18n_key = nil) 13 | @config = config 14 | @params = ActiveSupport::HashWithIndifferentAccess.new(params) 15 | end 16 | 17 | def augment_scope(scope) 18 | if active_scope 19 | scope.send(active_scope, ascending? ? :asc : :desc) 20 | else 21 | scope 22 | end 23 | end 24 | 25 | def is_scope_active?(scope) 26 | active_scope == scope.to_sym 27 | end 28 | 29 | def ascending? 30 | if params[:sort_order] == 'desc' 31 | false 32 | elsif params[:sort_order].blank? && active_scope == default_scope 33 | default_direction_is_ascending? 34 | else 35 | true 36 | end 37 | end 38 | 39 | def active_scope 40 | if param_value && scopes.include?(param_value.to_sym) 41 | param_value.to_sym 42 | elsif default_scope 43 | default_scope 44 | else 45 | nil 46 | end 47 | end 48 | 49 | def default_scope 50 | config.default_scope 51 | end 52 | 53 | def default_direction 54 | config.default_direction 55 | end 56 | 57 | def default_direction_is_ascending? 58 | default_direction != :desc 59 | end 60 | 61 | def param_value 62 | params.fetch(param_name, nil) 63 | end 64 | 65 | def param_name 66 | :sorting 67 | end 68 | 69 | def scopes 70 | config.scopes 71 | end 72 | end 73 | end 74 | end 75 | 76 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We love pull requests. Here's a quick guide: 4 | 5 | 1. Fork the repo. 6 | 2. Run the tests. We only take pull requests with passing tests, and it's great 7 | to know that you have a clean slate: `bundle && rake` 8 | 3. Add a test for your change. Only refactoring and documentation changes 9 | require no new tests. If you are adding functionality or fixing a bug, we need 10 | a test! 11 | 4. Make the test pass. 12 | 5. Make sure your changes adhere to the [thoughtbot Style 13 | Guide](https://github.com/thoughtbot/guides/tree/master/style) 14 | 6. Push to your fork and submit a pull request. 15 | 7. At this point you're waiting on us. We like to at least comment on, if not 16 | accept, pull requests within three business days (and, typically, one business 17 | day). [We may suggest some changes or improvements or 18 | alternatives](https://github.com/thoughtbot/guides/tree/master/code-review). 19 | 20 | ## Increase your chances of getting merged 21 | 22 | Some things that will increase the chance that your pull request is accepted, 23 | taken straight from the Ruby on Rails guide: 24 | 25 | * Use Rails idioms and helpers 26 | * Include tests that fail without your code, and pass with it 27 | * Update the documentation: that surrounding your change, examples elsewhere, 28 | guides, and whatever else is affected by your contribution 29 | * Syntax: 30 | * Two spaces, no tabs. 31 | * No trailing whitespace. Blank lines should not have any space. 32 | * Make sure your [source files end with newline 33 | characters](http://stackoverflow.com/questions/729692/why-should-files-end-with-a-newline#answer-729725). 34 | * Prefer `&&`/`||` over `and`/`or`. 35 | * `MyClass.my_method(my_arg)` not `my_method( my_arg )` or 36 | `my_method my_arg`. 37 | * `a = b` and not `a=b`. 38 | * Follow the conventions you see used in the source already. 39 | * And in case we didn't emphasize it enough: *We love tests!* 40 | 41 | -------------------------------------------------------------------------------- /lib/admino/action_view_extension.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/hash' 2 | require 'admino/table/presenter' 3 | require 'admino/query/base_presenter' 4 | 5 | module Admino 6 | module ActionViewExtension 7 | module Internals 8 | def self.present_query(query, context, options, key = :presenter) 9 | presenter_klass = options.fetch(key, Admino::Query::BasePresenter) 10 | presenter_klass.new(query, context) 11 | end 12 | end 13 | 14 | def table_for(collection, options = {}, &block) 15 | options.symbolize_keys! 16 | options.assert_valid_keys(:presenter, :class, :query, :html) 17 | presenter_klass = options.fetch(:presenter, Admino::Table::Presenter) 18 | query = if options[:query] 19 | Internals.present_query(options[:query], self, options, :query_presenter) 20 | else 21 | nil 22 | end 23 | presenter = presenter_klass.new(collection, options[:class], query, self) 24 | html_options = options.fetch(:html, {}) 25 | presenter.to_html(html_options, &block) 26 | end 27 | 28 | def filters_for(query, options = {}, &block) 29 | options.symbolize_keys! 30 | options.assert_valid_keys(:presenter) 31 | Internals.present_query(query, self, options).filter_groups.each(&block) 32 | end 33 | 34 | def sortings_for(query, options = {}, &block) 35 | options.symbolize_keys! 36 | options.assert_valid_keys(:presenter) 37 | Internals.present_query(query, self, options).sorting.scopes.each(&block) 38 | end 39 | 40 | def search_form_for(query, options = {}, &block) 41 | options.symbolize_keys! 42 | Internals.present_query(query, self, options.slice(:presenter)). 43 | form(options, &block) 44 | end 45 | 46 | def simple_search_form_for(query, options = {}, &block) 47 | options.symbolize_keys! 48 | Internals.present_query(query, self, options.slice(:presenter)). 49 | simple_form(options, &block) 50 | end 51 | end 52 | end 53 | 54 | -------------------------------------------------------------------------------- /spec/admino/table/row_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Admino 4 | module Table 5 | describe Row do 6 | subject(:row) { Row.new(view) } 7 | let(:view) { double('View Context') } 8 | 9 | it 'takes view context' do 10 | expect(row.view_context).to eq view 11 | end 12 | 13 | it 'aliases view_context to h' do 14 | expect(row.h).to eq view 15 | end 16 | 17 | describe '#parse_column_args' do 18 | subject do 19 | row.parse_column_args(arguments) 20 | end 21 | 22 | context 'with a symbol param' do 23 | let(:arguments) { [:title] } 24 | it { is_expected.to eq [:title, nil, {}] } 25 | end 26 | 27 | context 'with a string param' do 28 | let(:arguments) { ['Title'] } 29 | it { is_expected.to eq [nil, 'Title', {}] } 30 | end 31 | 32 | context 'with a symbol and string param' do 33 | let(:arguments) { [:title, 'Title'] } 34 | it { is_expected.to eq [:title, 'Title', {}] } 35 | end 36 | 37 | context 'with two symbol params' do 38 | let(:arguments) { [:title, :foo] } 39 | it { is_expected.to eq [:title, :foo, {}] } 40 | end 41 | 42 | context 'with options' do 43 | let(:arguments) { [{ foo: 'bar' }] } 44 | it { is_expected.to eq [nil, nil, { foo: 'bar' }] } 45 | end 46 | end 47 | 48 | describe '#parse_action_args' do 49 | subject do 50 | row.parse_action_args(arguments) 51 | end 52 | 53 | context 'with a symbol param' do 54 | let(:arguments) { [:show] } 55 | it { is_expected.to eq [:show, nil, nil, {}] } 56 | end 57 | 58 | context 'with a one string param' do 59 | let(:arguments) { ['/'] } 60 | it { is_expected.to eq [nil, '/', nil, {}] } 61 | end 62 | 63 | context 'with a two string params' do 64 | let(:arguments) { ['/', 'Details'] } 65 | it { is_expected.to eq [nil, '/', 'Details', {}] } 66 | end 67 | 68 | context 'with options' do 69 | let(:arguments) { [{ foo: 'bar' }] } 70 | it { is_expected.to eq [nil, nil, nil, { foo: 'bar' }] } 71 | end 72 | end 73 | end 74 | end 75 | end 76 | 77 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | require 'coveralls' 3 | 4 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[ 5 | SimpleCov::Formatter::HTMLFormatter, 6 | Coveralls::SimpleCov::Formatter 7 | ] 8 | 9 | SimpleCov.start do 10 | add_filter 'spec' 11 | end 12 | 13 | require 'admino' 14 | require 'pry' 15 | require 'rspec-html-matchers' 16 | 17 | I18n.enforce_available_locales = true 18 | I18n.available_locales = [:en] 19 | I18n.locale = :en 20 | 21 | class ScopeMock 22 | attr_reader :chain, :name 23 | 24 | def initialize(name = nil, chain = []) 25 | @name = name 26 | @chain = chain 27 | end 28 | 29 | def method_missing(method_name, *args, &block) 30 | ::ScopeMock.new(name, chain + [method_name, args]) 31 | end 32 | end 33 | 34 | class TestQuery < Admino::Query::Base 35 | search_field :foo, default: 'bar' 36 | search_field :starting_from, coerce: :to_date 37 | 38 | filter_by :bar, [:one, :two], 39 | include_empty_scope: true, 40 | default: :two 41 | 42 | sorting :by_title, :by_date, 43 | default_scope: :by_title, 44 | default_direction: :desc 45 | 46 | starting_scope { 'start' } 47 | ending_scope { 'end' } 48 | end 49 | 50 | class Post < Struct.new(:key, :dom_id) 51 | extend ActiveModel::Naming 52 | extend ActiveModel::Translation 53 | 54 | def title 55 | "Post #{key}" 56 | end 57 | 58 | def author_name 59 | "steffoz" 60 | end 61 | 62 | def to_param 63 | key 64 | end 65 | 66 | def to_key 67 | [key] 68 | end 69 | end 70 | 71 | module Rails 72 | def self.env 73 | OpenStruct.new(development?: false) 74 | end 75 | end 76 | 77 | require 'action_view' 78 | require 'simple_form' 79 | 80 | class RailsViewContext < ActionView::Base 81 | include ActionView::Helpers::TagHelper 82 | include SimpleForm::ActionViewExtensions::FormHelper 83 | include Admino::ActionViewExtension 84 | 85 | def request 86 | OpenStruct.new(path: '/', fullpath: '/?p=1') 87 | end 88 | 89 | def polymorphic_path(*args) 90 | '/' 91 | end 92 | end 93 | 94 | RSpec.configure do |c| 95 | c.include RSpecHtmlMatchers 96 | 97 | c.before(:each) do 98 | I18n.backend = I18n::Backend::Simple.new 99 | end 100 | end 101 | 102 | -------------------------------------------------------------------------------- /lib/admino/query/filter_group_presenter.rb: -------------------------------------------------------------------------------- 1 | require 'i18n' 2 | require 'showcase' 3 | require 'showcase/helpers/html_options' 4 | require 'active_support/core_ext/object/deep_dup' 5 | require 'active_support/core_ext/array/extract_options' 6 | require 'active_support/hash_with_indifferent_access' 7 | require 'active_support/core_ext/hash' 8 | 9 | module Admino 10 | module Query 11 | class FilterGroupPresenter < Showcase::Presenter 12 | def scope_link(scope, *args) 13 | options = args.extract_options! 14 | 15 | label = args.first || scope_name(scope) 16 | 17 | active_class = options.delete(:active_class) { 'is-active' } 18 | options = Showcase::Helpers::HtmlOptions.new(options) 19 | 20 | if is_scope_active?(scope) 21 | options.add_class!(active_class) 22 | end 23 | 24 | h.link_to label, scope_path(scope), options.to_h 25 | end 26 | 27 | def scopes 28 | @scopes ||= super.map do |scope| 29 | ScopePresenter.new(scope, self, view_context) 30 | end 31 | end 32 | 33 | def each_scope(&block) 34 | scopes.each(&block) 35 | end 36 | 37 | def scope_path(scope) 38 | h.request.path + "?" + scope_params(scope).to_query 39 | end 40 | 41 | def scope_params(scope) 42 | params = ActiveSupport::HashWithIndifferentAccess.new( 43 | h.request.query_parameters.deep_dup 44 | ) 45 | 46 | params[:query] ||= {} 47 | 48 | if is_scope_active?(scope) 49 | params[:query].delete(param_name) 50 | else 51 | params[:query].merge!(param_name => scope.to_s) 52 | end 53 | 54 | if params[:query].empty? 55 | params.delete(:query) 56 | end 57 | 58 | params 59 | end 60 | 61 | def scope_name(scope) 62 | I18n.t( 63 | :"#{query_i18n_key}.#{i18n_key}.scopes.#{scope}", 64 | scope: 'query.filter_groups', 65 | default: [ 66 | :"#{i18n_key}.scopes.#{scope}", 67 | scope.to_s.titleize 68 | ] 69 | ) 70 | end 71 | 72 | def name 73 | I18n.t( 74 | :"#{query_i18n_key}.#{i18n_key}.name", 75 | scope: 'query.filter_groups', 76 | default: [ 77 | :"#{i18n_key}.name", 78 | i18n_key.to_s.titleize.capitalize 79 | ] 80 | ) 81 | end 82 | end 83 | end 84 | end 85 | 86 | -------------------------------------------------------------------------------- /spec/admino/query/search_field_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Admino 4 | module Query 5 | describe SearchField do 6 | subject(:search_field) { SearchField.new(config, params) } 7 | let(:config) { Configuration::SearchField.new(:foo) } 8 | let(:params) { {} } 9 | 10 | describe '#value' do 11 | context 'with a value' do 12 | let(:params) { { 'query' => { 'foo' => 'bar' } } } 13 | 14 | it 'returns the param value for the search_field' do 15 | expect(search_field.value).to eq 'bar' 16 | end 17 | end 18 | 19 | context 'else' do 20 | it 'returns nil' do 21 | expect(search_field.value).to be_nil 22 | end 23 | 24 | context 'with a default value' do 25 | let(:config) { 26 | Configuration::SearchField.new(:foo, default: 'foo') 27 | } 28 | 29 | it 'returns it' do 30 | expect(search_field.value).to eq 'foo' 31 | end 32 | end 33 | end 34 | 35 | context 'with coertion' do 36 | let(:config) { 37 | Configuration::SearchField.new(:foo, coerce: :to_date) 38 | } 39 | 40 | context 'with a possible coertion' do 41 | let(:params) { { 'query' => { 'foo' => '2014-10-05' } } } 42 | 43 | it 'returns the coerced param value for the search_field' do 44 | expect(search_field.value).to be_a Date 45 | end 46 | end 47 | 48 | context 'with a possible coertion' do 49 | let(:params) { { 'query' => { 'foo' => '' } } } 50 | 51 | it 'returns nil' do 52 | expect(search_field.value).to be_nil 53 | end 54 | end 55 | end 56 | end 57 | 58 | describe '#augment_scope' do 59 | let(:result) { search_field.augment_scope(scope) } 60 | let(:scope) { ScopeMock.new('original') } 61 | 62 | context 'if the search_field has a value' do 63 | let(:params) { { 'query' => { 'foo' => 'bar' } } } 64 | 65 | it 'returns the original scope chained with the search_field scope' do 66 | expect(result.chain).to eq [:foo, ['bar']] 67 | end 68 | end 69 | 70 | context 'else' do 71 | it 'returns the original scope' do 72 | expect(result).to eq scope 73 | end 74 | end 75 | end 76 | end 77 | end 78 | end 79 | 80 | -------------------------------------------------------------------------------- /lib/admino/table/head_row.rb: -------------------------------------------------------------------------------- 1 | require 'admino/table/row' 2 | require 'showcase/helpers/html_options' 3 | 4 | module Admino 5 | module Table 6 | class HeadRow < Row 7 | attr_reader :resource_klass 8 | attr_reader :query 9 | 10 | def initialize(resource_klass, query, view_context) 11 | @resource_klass = resource_klass 12 | @query = query 13 | @columns = "" 14 | 15 | super(view_context) 16 | end 17 | 18 | def actions(*args, &block) 19 | default_options = column_html_options(:actions) 20 | label = I18n.t( 21 | :"#{resource_klass.model_name.i18n_key}.title", 22 | scope: 'table.actions', 23 | default: [ 24 | :title, 25 | 'Actions' 26 | ] 27 | ) 28 | 29 | @columns << h.content_tag(:th, label.to_s, default_options) 30 | end 31 | 32 | def column(*args, &block) 33 | attribute_name, label, html_options = parse_column_args(args) 34 | 35 | if label.nil? 36 | label = column_label(attribute_name) 37 | elsif label.is_a? Symbol 38 | label = column_label(label) 39 | end 40 | 41 | html_options = complete_column_html_options( 42 | attribute_name, 43 | html_options 44 | ) 45 | 46 | sorting_scope = html_options.delete(:sorting) 47 | sorting_html_options = html_options.delete(:sorting_html_options) { {} } 48 | 49 | if sorting_scope 50 | raise ArgumentError, 'query object is required' unless query 51 | label = query.sorting.scope_link(sorting_scope, label, sorting_html_options) 52 | end 53 | 54 | @columns << h.content_tag(:th, label.to_s, html_options) 55 | end 56 | 57 | def to_html 58 | @columns.html_safe 59 | end 60 | 61 | private 62 | 63 | def column_label(attribute_name) 64 | if attribute_name 65 | resource_klass.human_attribute_name(attribute_name.to_s) 66 | end 67 | end 68 | 69 | def complete_column_html_options(attribute_name, final_html_options) 70 | if attribute_name.nil? 71 | return final_html_options 72 | end 73 | 74 | default_options = column_html_options(attribute_name) 75 | html_options = Showcase::Helpers::HtmlOptions.new(default_options) 76 | html_options.merge_attrs!(final_html_options) 77 | html_options = html_options.to_h 78 | end 79 | 80 | def column_html_options(attribute_name) 81 | { role: attribute_name.to_s.gsub(/_/, '-') } 82 | end 83 | end 84 | end 85 | end 86 | 87 | -------------------------------------------------------------------------------- /spec/admino/query/base_presenter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Admino 4 | module Query 5 | describe BasePresenter do 6 | subject(:presenter) { BasePresenter.new(query, view) } 7 | let(:view) { RailsViewContext.new } 8 | let(:query) do 9 | TestQuery.new(query: { foo: 'value' }) 10 | end 11 | let(:request_object) do 12 | double( 13 | 'ActionDispatch::Request', 14 | fullpath: '/?foo=bar' 15 | ) 16 | end 17 | 18 | before do 19 | allow(view).to receive(:request).and_return(request_object) 20 | end 21 | 22 | describe '#form' do 23 | let(:result) do 24 | presenter.form do |form| 25 | form.label(:foo) << 26 | form.text_field(:foo) 27 | end 28 | end 29 | 30 | before do 31 | I18n.backend.store_translations( 32 | :en, 33 | query: { attributes: { test_query: { foo: 'NAME' } } } 34 | ) 35 | end 36 | 37 | it 'renders a form pointing to the current URL' do 38 | expect(result).to have_tag(:form, with: { action: '/?foo=bar' }) 39 | end 40 | 41 | it 'renders a form with method GET' do 42 | expect(result).to have_tag(:form, with: { method: 'get' }) 43 | end 44 | 45 | it 'renders inputs with a query[] name prefix' do 46 | expect(result).to have_tag(:input, with: { type: 'text', name: 'query[foo]' }) 47 | end 48 | 49 | it 'prefills inputs with query value' do 50 | expect(result).to have_tag(:input, with: { type: 'text', value: 'value' }) 51 | end 52 | 53 | it 'it generates labels in the :query I18n space' do 54 | expect(result).to have_tag(:label, text: 'NAME') 55 | end 56 | end 57 | 58 | describe '#simple_form' do 59 | let(:result) do 60 | presenter.simple_form do |form| 61 | form.input(:foo) 62 | end 63 | end 64 | 65 | before do 66 | I18n.backend.store_translations( 67 | :en, 68 | query: { attributes: { test_query: { foo: 'NAME' } } } 69 | ) 70 | end 71 | 72 | it 'renders a form pointing to the current URL' do 73 | expect(result).to have_tag(:form, with: { action: '/?foo=bar' }) 74 | end 75 | 76 | it 'renders a form with method GET' do 77 | expect(result).to have_tag(:form, with: { method: 'get' }) 78 | end 79 | 80 | it 'renders inputs with a query[] name prefix' do 81 | expect(result).to have_tag(:input, with: { type: 'text', name: 'query[foo]' }) 82 | end 83 | 84 | it 'prefills inputs with query value' do 85 | expect(result).to have_tag(:input, with: { type: 'text', value: 'value' }) 86 | end 87 | 88 | it 'it generates labels in the :query I18n space' do 89 | expect(result).to have_tag(:label, content: 'NAME') 90 | end 91 | end 92 | end 93 | end 94 | end 95 | 96 | -------------------------------------------------------------------------------- /lib/admino/query/configuration.rb: -------------------------------------------------------------------------------- 1 | module Admino 2 | module Query 3 | class Configuration 4 | class SearchField 5 | attr_reader :name 6 | attr_reader :options 7 | 8 | def initialize(name, options = {}) 9 | options.symbolize_keys! 10 | options.assert_valid_keys( 11 | :coerce, 12 | :default 13 | ) 14 | 15 | @name = name.to_sym 16 | @options = options 17 | end 18 | 19 | def default_value 20 | options[:default] 21 | end 22 | 23 | def coerce_to 24 | if options[:coerce] 25 | options[:coerce].to_sym 26 | end 27 | end 28 | end 29 | 30 | class FilterGroup 31 | attr_reader :name 32 | attr_reader :scopes 33 | attr_reader :options 34 | 35 | def initialize(name, scopes, options = {}) 36 | options.symbolize_keys! 37 | options.assert_valid_keys( 38 | :include_empty_scope, 39 | :default 40 | ) 41 | 42 | @name = name.to_sym 43 | @scopes = scopes.map(&:to_sym) 44 | @options = options 45 | end 46 | 47 | def include_empty_scope? 48 | @options.fetch(:include_empty_scope) { false } 49 | end 50 | 51 | def default_scope 52 | if options[:default] 53 | options[:default].to_sym 54 | end 55 | end 56 | end 57 | 58 | class Sorting 59 | attr_reader :scopes 60 | attr_reader :default_scope 61 | attr_reader :default_direction 62 | 63 | def initialize(scopes, options = {}) 64 | options.symbolize_keys! 65 | options.assert_valid_keys(:default_scope, :default_direction) 66 | 67 | @scopes = scopes.map(&:to_sym) 68 | @default_scope = if options[:default_scope] 69 | options[:default_scope].to_sym 70 | end 71 | 72 | @default_direction = if options[:default_direction] 73 | options[:default_direction].to_sym 74 | end 75 | end 76 | end 77 | 78 | attr_reader :search_fields 79 | attr_reader :filter_groups 80 | attr_reader :sorting 81 | attr_accessor :starting_scope_callable 82 | attr_accessor :ending_scope_callable 83 | 84 | def initialize 85 | @search_fields = [] 86 | @filter_groups = [] 87 | end 88 | 89 | def add_search_field(name, options = {}) 90 | SearchField.new(name, options).tap do |search_field| 91 | self.search_fields << search_field 92 | end 93 | end 94 | 95 | def add_filter_group(name, scopes, options = {}) 96 | FilterGroup.new(name, scopes, options).tap do |filter_group| 97 | self.filter_groups << filter_group 98 | end 99 | end 100 | 101 | def add_sorting_scopes(scopes, options = {}) 102 | @sorting = Sorting.new(scopes, options) 103 | end 104 | end 105 | end 106 | end 107 | 108 | -------------------------------------------------------------------------------- /lib/admino/table/presenter.rb: -------------------------------------------------------------------------------- 1 | require 'showcase' 2 | require 'admino/table/head_row' 3 | require 'admino/table/resource_row' 4 | 5 | module Admino 6 | module Table 7 | class Presenter < Showcase::Presenter 8 | attr_reader :collection_klass 9 | attr_reader :query 10 | 11 | def self.tag_helper(name, tag, options = {}) 12 | options_method = :"#{name}_html_options" 13 | 14 | define_method :"#{name}_tag" do |*args, &block| 15 | options = args.extract_options! 16 | if respond_to?(options_method, true) 17 | default_options = send(options_method, *args) 18 | html_options = Showcase::Helpers::HtmlOptions.new(default_options) 19 | html_options.merge_attrs!(options) 20 | options = html_options.to_h 21 | end 22 | h.content_tag(tag, options, &block) 23 | end 24 | end 25 | 26 | tag_helper :table, :table 27 | tag_helper :thead, :thead 28 | tag_helper :thead_tr, :tr 29 | tag_helper :tbody, :tbody 30 | tag_helper :tbody_tr, :tr, params: %w(resource index) 31 | 32 | def initialize(*args) 33 | context = args.pop 34 | collection = args.shift 35 | 36 | @collection_klass = args.shift 37 | 38 | if !@collection_klass && !collection.empty? 39 | @collection_klass = collection.first.class 40 | end 41 | 42 | @query = args.shift 43 | 44 | super(collection, context) 45 | end 46 | 47 | def to_html(options = {}, &block) 48 | 49 | if @collection_klass.nil? 50 | raise ArgumentError, 'collection is empty and no explicit class is specified' 51 | end 52 | 53 | table_tag(options) do 54 | thead_tag do 55 | thead_tr_tag do 56 | th_tags(&block) 57 | end 58 | end << 59 | tbody_tag do 60 | tbody_tr_tags(&block) 61 | end 62 | end 63 | end 64 | 65 | private 66 | 67 | def tbody_tr_tags(&block) 68 | collection.each_with_index.map do |resource, index| 69 | html_options = base_tbody_tr_html_options(resource, index) 70 | tbody_tr_tag(resource, index, html_options) do 71 | td_tags(resource, &block) 72 | end 73 | end.join.html_safe 74 | end 75 | 76 | def th_tags(&block) 77 | row = head_row(collection_klass, query, view_context) 78 | if block_given? 79 | h.capture(row, nil, &block) 80 | end 81 | row.to_html 82 | end 83 | 84 | def td_tags(resource, &block) 85 | row = resource_row(resource, view_context) 86 | if block_given? 87 | h.capture(row, resource, &block) 88 | end 89 | row.to_html 90 | end 91 | 92 | def collection 93 | object 94 | end 95 | 96 | def head_row(collection_klass, query, view_context) 97 | HeadRow.new(collection_klass, query, view_context) 98 | end 99 | 100 | def resource_row(resource, view_context) 101 | ResourceRow.new(resource, view_context) 102 | end 103 | 104 | def base_tbody_tr_html_options(resource, index) 105 | options = { 106 | class: zebra_css_classes[index % zebra_css_classes.size] 107 | } 108 | 109 | if resource.respond_to?(:dom_id) 110 | options[:id] = resource.dom_id 111 | end 112 | 113 | options 114 | end 115 | 116 | def zebra_css_classes 117 | %w(is-even is-odd) 118 | end 119 | end 120 | end 121 | end 122 | 123 | -------------------------------------------------------------------------------- /lib/admino/query/base.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/concern' 2 | require 'active_model/naming' 3 | require 'active_model/translation' 4 | require 'active_model/conversion' 5 | require 'active_support/hash_with_indifferent_access' 6 | require 'active_support/core_ext/hash' 7 | 8 | require 'admino/query/dsl' 9 | require 'admino/query/builder' 10 | 11 | module Admino 12 | module Query 13 | class Base 14 | extend ActiveModel::Naming 15 | extend ActiveModel::Translation 16 | include ActiveModel::Conversion 17 | extend Dsl 18 | 19 | attr_reader :params 20 | attr_reader :context 21 | attr_reader :filter_groups 22 | attr_reader :search_fields 23 | attr_reader :sorting 24 | 25 | def self.i18n_scope 26 | :query 27 | end 28 | 29 | def initialize(params = nil, context = {}, config = nil) 30 | @params = 31 | if params.respond_to?(:to_unsafe_h) 32 | ActiveSupport::HashWithIndifferentAccess.new(params.to_unsafe_h) 33 | else 34 | ActiveSupport::HashWithIndifferentAccess.new(params) 35 | end 36 | @config = config 37 | @context = context 38 | 39 | init_filter_groups 40 | init_search_fields 41 | init_sorting 42 | end 43 | 44 | def scope(starting_scope = nil) 45 | starting_scope ||= if config.starting_scope_callable 46 | config.starting_scope_callable.call(self) 47 | else 48 | raise ArgumentError, 'no starting scope provided' 49 | end 50 | 51 | scope = augment_scope(Builder.new(self, starting_scope)).scope 52 | 53 | if config.ending_scope_callable 54 | scope.instance_exec(self, &config.ending_scope_callable) 55 | else 56 | scope 57 | end 58 | end 59 | 60 | def persisted? 61 | false 62 | end 63 | 64 | def config 65 | @config || self.class.config 66 | end 67 | 68 | def filter_groups 69 | @filter_groups.values 70 | end 71 | 72 | def filter_group_by_name(name) 73 | @filter_groups[name] 74 | end 75 | 76 | def search_fields 77 | @search_fields.values 78 | end 79 | 80 | def search_field_by_name(name) 81 | @search_fields[name] 82 | end 83 | 84 | private 85 | 86 | def augment_scope(query_builder) 87 | scope_augmenters.each do |augmenter| 88 | query_builder = augmenter.augment_scope(query_builder) 89 | end 90 | 91 | query_builder 92 | end 93 | 94 | def scope_augmenters 95 | scope_augmenters = search_fields + filter_groups 96 | scope_augmenters << sorting if sorting 97 | scope_augmenters 98 | end 99 | 100 | def init_filter_groups 101 | @filter_groups = {} 102 | i18n_key = self.class.model_name.i18n_key 103 | config.filter_groups.each do |config| 104 | @filter_groups[config.name] = FilterGroup.new(config, params, i18n_key) 105 | end 106 | end 107 | 108 | def init_search_fields 109 | @search_fields = {} 110 | config.search_fields.each do |config| 111 | @search_fields[config.name] = SearchField.new(config, params) 112 | end 113 | end 114 | 115 | def init_sorting 116 | if config.sorting 117 | i18n_key = self.class.model_name.i18n_key 118 | @sorting = Sorting.new(config.sorting, params, i18n_key) 119 | end 120 | end 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /spec/admino/integration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Action View integration' do 4 | let(:context) { RailsViewContext.new } 5 | 6 | describe '#simple_table_for' do 7 | let(:collection) { [ first_post, second_post ] } 8 | let(:first_post) { Post.new('1') } 9 | let(:second_post) { Post.new('2') } 10 | let(:params) { 11 | { 12 | sorting: 'by_title', 13 | sort_order: 'desc' 14 | } 15 | } 16 | let(:query) { TestQuery.new(params) } 17 | 18 | it 'produces HTML' do 19 | result = context.table_for(collection, query: query) do |table, record| 20 | table.column :title, sorting: :by_title 21 | table.actions do 22 | table.action :show, '/foo', 'Show' 23 | end 24 | end 25 | 26 | expect(result).to have_tag(:table) do 27 | with_tag 'thead th:first-child', with: { role: 'title' } 28 | with_tag 'thead th:last-child', with: { role: 'actions' } 29 | 30 | with_tag 'th:first-child a', with: { class: 'is-desc', href: '/?sort_order=asc&sorting=by_title' }, text: 'Title' 31 | with_tag 'th:last-child', text: 'Actions' 32 | 33 | with_tag 'tbody tr:first-child', with: { class: 'is-even' } 34 | with_tag 'tbody tr:last-child', with: { class: 'is-odd' } 35 | 36 | with_tag 'tbody tr:first-child td:first-child', with: { role: 'title' }, text: 'Post 1' 37 | with_tag 'tbody tr:first-child td:last-child', with: { role: 'actions' } 38 | 39 | with_tag 'tbody tr:first-child td:last-child a', with: { role: 'show', href: '/foo' } 40 | end 41 | end 42 | end 43 | 44 | describe '#filters_for' do 45 | let(:params) { 46 | { 47 | query: { bar: 'one' } 48 | } 49 | } 50 | let(:query) { TestQuery.new(params) } 51 | 52 | it 'produces HTML' do 53 | result = "" 54 | context.filters_for(query) do |group| 55 | result << "#{group.name}: " 56 | group.each_scope do |scope| 57 | result << scope.link 58 | end 59 | end 60 | 61 | expect(result).to have_tag(:a, with: { href: '/?query%5Bbar%5D=empty' }) do 62 | with_text 'Empty' 63 | end 64 | 65 | expect(result).to have_tag(:a, with: { class: 'is-active', href: '/?' }) do 66 | with_text 'One' 67 | end 68 | 69 | expect(result).to have_tag(:a, with: { href: '/?query%5Bbar%5D=two' }) do 70 | with_text 'Two' 71 | end 72 | end 73 | end 74 | 75 | describe '#sortings_for' do 76 | let(:params) { 77 | { 78 | sorting: 'by_title', 79 | sort_order: 'desc' 80 | } 81 | } 82 | let(:query) { TestQuery.new(params) } 83 | 84 | it 'produces HTML' do 85 | result = "" 86 | context.sortings_for(query) do |scope| 87 | result << scope.link 88 | end 89 | 90 | expect(result).to have_tag(:a, with: { class: 'is-desc', href: '/?sort_order=asc&sorting=by_title' }) do 91 | with_text 'By title' 92 | end 93 | 94 | expect(result).to have_tag(:a, with: { href: '/?sort_order=asc&sorting=by_date' }) do 95 | with_text 'By date' 96 | end 97 | end 98 | end 99 | 100 | describe '#search_form_for' do 101 | let(:params) { 102 | { 103 | query: { foo: 'test' } 104 | } 105 | } 106 | let(:query) { TestQuery.new(params) } 107 | 108 | it 'produces HTML' do 109 | result = context.search_form_for(query) do |f| 110 | f.text_field :foo 111 | end 112 | 113 | expect(result).to have_tag(:form, with: { action: '/?p=1', method: 'get' }) do 114 | with_tag 'input', with: { type: 'text', value: 'test', name: 'query[foo]' } 115 | end 116 | end 117 | end 118 | 119 | describe '#simple_search_form_for' do 120 | let(:params) { 121 | { 122 | query: { foo: 'test' } 123 | } 124 | } 125 | let(:query) { TestQuery.new(params) } 126 | 127 | it 'produces HTML' do 128 | result = context.simple_search_form_for(query) do |f| 129 | f.input :foo 130 | end 131 | 132 | expect(result).to have_tag(:form, with: { action: '/?p=1', method: 'get' }) do 133 | with_tag 'input', with: { type: 'text', value: 'test', name: 'query[foo]' } 134 | end 135 | end 136 | end 137 | end 138 | 139 | -------------------------------------------------------------------------------- /spec/admino/query/filter_group_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Admino 4 | module Query 5 | describe FilterGroup do 6 | subject(:filter_group) { FilterGroup.new(config, params) } 7 | let(:config) do 8 | Configuration::FilterGroup.new(:foo, [:bar, :other], options) 9 | end 10 | let(:options) { {} } 11 | let(:params) { {} } 12 | 13 | describe '#active_scope' do 14 | context 'with no param' do 15 | let(:params) { {} } 16 | 17 | it 'returns nil' do 18 | expect(filter_group.active_scope).to be_nil 19 | end 20 | 21 | context 'if include_empty_scope is true' do 22 | let(:options) { { include_empty_scope: true } } 23 | 24 | it 'returns the :empty scope' do 25 | expect(filter_group.active_scope).to eq :empty 26 | end 27 | 28 | context 'if default scope is set' do 29 | let(:config) do 30 | Configuration::FilterGroup.new( 31 | :foo, 32 | [:bar], 33 | include_empty_scope: true, 34 | default: :bar 35 | ) 36 | end 37 | 38 | it 'returns it' do 39 | expect(filter_group.active_scope).to eq :bar 40 | end 41 | end 42 | end 43 | end 44 | 45 | context 'with "empty" param' do 46 | let(:params) { { 'query' => { 'foo' => 'empty' } } } 47 | 48 | context 'if include_empty_scope is true' do 49 | let(:options) { { include_empty_scope: true, default: :bar } } 50 | 51 | it 'returns the :empty scope' do 52 | expect(filter_group.active_scope).to eq :empty 53 | end 54 | end 55 | end 56 | 57 | context 'with an invalid value' do 58 | let(:params) { { 'query' => { 'foo' => 'qux' } } } 59 | 60 | it 'returns nil' do 61 | expect(filter_group.active_scope).to be_nil 62 | end 63 | 64 | context 'if include_empty_scope is true' do 65 | let(:options) { { include_empty_scope: true, default: :bar } } 66 | 67 | it 'returns nil' do 68 | expect(filter_group.active_scope).to be_nil 69 | end 70 | end 71 | end 72 | 73 | context 'with a valid value' do 74 | let(:params) { { 'query' => { 'foo' => 'bar' } } } 75 | 76 | it 'returns the scope name' do 77 | expect(filter_group.active_scope).to eq :bar 78 | end 79 | end 80 | end 81 | 82 | describe '#augment_scope' do 83 | let(:result) { filter_group.augment_scope(scope) } 84 | let(:scope) { ScopeMock.new('original') } 85 | 86 | context 'if the search_field has a value' do 87 | let(:params) { { 'query' => { 'foo' => 'bar' } } } 88 | 89 | it 'returns the original scope chained with the filter_group scope' do 90 | expect(result.chain).to eq [:bar, []] 91 | end 92 | end 93 | 94 | context 'else' do 95 | it 'returns the original scope' do 96 | expect(result).to eq scope 97 | end 98 | 99 | context 'if include_empty_scope is true' do 100 | let(:options) { { include_empty_scope: true } } 101 | 102 | it 'returns the original scope' do 103 | expect(result).to eq scope 104 | end 105 | end 106 | end 107 | end 108 | 109 | describe '#is_scope_active?' do 110 | let(:params) { { 'query' => { 'foo' => 'bar' } } } 111 | 112 | it 'returns true if the provided scope is the one currently active' do 113 | expect(filter_group.is_scope_active?('bar')).to be_truthy 114 | end 115 | end 116 | 117 | describe '#scopes' do 118 | subject { filter_group.scopes } 119 | 120 | context 'if include_empty_scope is true' do 121 | let(:options) { { include_empty_scope: true } } 122 | 123 | it { is_expected.to eq [:empty, :bar, :other] } 124 | end 125 | 126 | context 'else' do 127 | it { is_expected.to eq [:bar, :other] } 128 | end 129 | end 130 | end 131 | end 132 | end 133 | 134 | -------------------------------------------------------------------------------- /lib/admino/table/resource_row.rb: -------------------------------------------------------------------------------- 1 | require 'admino/table/row' 2 | require 'showcase/helpers/html_options' 3 | 4 | module Admino 5 | module Table 6 | class ResourceRow < Row 7 | attr_reader :resource 8 | 9 | def initialize(resource, view_context) 10 | @resource = resource 11 | @columns = "" 12 | @actions = [] 13 | 14 | super(view_context) 15 | end 16 | 17 | def column(*args, &block) 18 | attribute_name, label, html_options = parse_column_args(args) 19 | 20 | html_options = complete_column_html_options( 21 | attribute_name, 22 | html_options 23 | ) 24 | 25 | if block_given? 26 | content = h.capture(resource, &block) 27 | elsif attribute_name.present? 28 | content = resource.send(attribute_name) 29 | else 30 | raise ArgumentError, 'attribute name or block required' 31 | end 32 | 33 | @columns << h.content_tag(:td, content, html_options) 34 | end 35 | 36 | def actions(*actions, &block) 37 | if block_given? 38 | h.capture(&block) 39 | else 40 | actions.each do |action| 41 | action(action) 42 | end 43 | end 44 | end 45 | 46 | def action(*args, &block) 47 | if block_given? 48 | @actions << h.capture(resource, &block) 49 | else 50 | action_name, url, label, html_options = parse_action_args(args) 51 | 52 | label ||= action_label(action_name) 53 | url ||= action_url(action_name) 54 | html_options = complete_action_html_options( 55 | action_name, 56 | html_options 57 | ) 58 | 59 | @actions << h.link_to(label, url, html_options) 60 | end 61 | end 62 | 63 | def to_html 64 | buffer = @columns 65 | 66 | if @actions.any? 67 | html_options = column_html_options(:actions) 68 | buffer << h.content_tag(:td, html_options) do 69 | @actions.join(" ").html_safe 70 | end 71 | end 72 | 73 | buffer.html_safe 74 | end 75 | 76 | private 77 | 78 | def action_url(action_name) 79 | if action_name.nil? 80 | raise ArgumentError, 81 | 'no URL provided, action name required' 82 | end 83 | 84 | action_url_method = "#{action_name}_action_url" 85 | 86 | if !respond_to?(action_url_method, true) 87 | raise ArgumentError, 88 | "no URL provided, ##{action_url_method} method required" 89 | end 90 | 91 | url = send(action_url_method) 92 | end 93 | 94 | def complete_action_html_options(action_name, final_html_options) 95 | if action_name 96 | default_options = action_html_options(action_name) 97 | html_options = Showcase::Helpers::HtmlOptions.new(default_options) 98 | 99 | action_html_options_method = "#{action_name}_action_html_options" 100 | if respond_to?(action_html_options_method, true) 101 | html_options.merge_attrs!(send(action_html_options_method)) 102 | end 103 | 104 | html_options.merge_attrs!(final_html_options) 105 | html_options.to_h 106 | else 107 | final_html_options 108 | end 109 | end 110 | 111 | def complete_column_html_options(attribute_name, final_html_options) 112 | if attribute_name 113 | default_options = column_html_options(attribute_name) 114 | html_options = Showcase::Helpers::HtmlOptions.new(default_options) 115 | html_options.merge_attrs!(final_html_options) 116 | html_options.to_h 117 | else 118 | final_html_options 119 | end 120 | end 121 | 122 | def action_label(action_name) 123 | return nil unless action_name 124 | 125 | I18n.t( 126 | :"#{resource.class.model_name.i18n_key}.#{action_name}", 127 | scope: 'table.actions', 128 | default: [ 129 | :"#{action_name}", 130 | action_name.to_s.titleize 131 | ] 132 | ) 133 | end 134 | 135 | def action_html_options(action_name) 136 | { role: action_name.to_s.gsub(/_/, '-') } 137 | end 138 | 139 | def column_html_options(attribute_name) 140 | { role: attribute_name.to_s.gsub(/_/, '-') } 141 | end 142 | end 143 | end 144 | end 145 | 146 | -------------------------------------------------------------------------------- /spec/admino/query/sorting_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Admino 4 | module Query 5 | describe Sorting do 6 | subject(:sorting) { Sorting.new(config, params) } 7 | let(:config) do 8 | Configuration::Sorting.new(sorting_scopes, options) 9 | end 10 | let(:sorting_scopes) { [:by_title, :by_date] } 11 | let(:options) { {} } 12 | let(:params) { {} } 13 | 14 | describe '#active_scope' do 15 | context 'with no param' do 16 | let(:params) { {} } 17 | 18 | it 'returns false' do 19 | expect(sorting.active_scope).to be_nil 20 | end 21 | 22 | context 'if a default scope is configured' do 23 | let(:options) { { default_scope: :by_date } } 24 | 25 | it 'returns it' do 26 | expect(sorting.active_scope).to eq :by_date 27 | end 28 | end 29 | end 30 | 31 | context 'with an invalid value' do 32 | let(:params) { { 'sorting' => 'foo' } } 33 | 34 | it 'returns false' do 35 | expect(sorting.active_scope).to be_nil 36 | end 37 | end 38 | 39 | context 'with a valid value' do 40 | let(:params) { { 'sorting' => 'by_title' } } 41 | 42 | it 'returns the scope name' do 43 | expect(sorting.active_scope).to eq :by_title 44 | end 45 | end 46 | end 47 | 48 | describe '#ascending?' do 49 | context 'with no param' do 50 | let(:params) { {} } 51 | 52 | it 'returns true' do 53 | expect(sorting).to be_ascending 54 | end 55 | end 56 | 57 | context 'with invalid value' do 58 | let(:params) { { 'sort_order' => 'foo' } } 59 | 60 | it 'returns true' do 61 | expect(sorting).to be_ascending 62 | end 63 | end 64 | 65 | context 'with "asc" value' do 66 | let(:params) { { 'sort_order' => 'asc' } } 67 | 68 | it 'returns nil' do 69 | expect(sorting).to be_ascending 70 | end 71 | end 72 | 73 | context 'with "desc" value' do 74 | let(:params) { { 'sort_order' => 'desc' } } 75 | 76 | it 'returns the param value for the search_field' do 77 | expect(sorting).not_to be_ascending 78 | end 79 | end 80 | 81 | context 'if a default scope and direction are set and default scope is current' do 82 | let(:options) { { default_scope: :by_date, default_direction: :desc } } 83 | let(:params) { { 'sorting' => 'by_date', 'sort_order' => 'desc' } } 84 | 85 | it 'returns it' do 86 | expect(sorting).not_to be_ascending 87 | end 88 | end 89 | end 90 | 91 | describe '#augment_scope' do 92 | let(:result) { sorting.augment_scope(scope) } 93 | let(:scope) { ScopeMock.new('original') } 94 | 95 | context 'if the search_field has a value' do 96 | let(:params) { { 'sorting' => 'by_title', 'sort_order' => 'desc' } } 97 | 98 | it 'returns the original scope chained with the current scope' do 99 | expect(result.chain).to eq [:by_title, [:desc]] 100 | end 101 | end 102 | 103 | context 'else' do 104 | it 'returns the original scope' do 105 | expect(result).to eq scope 106 | end 107 | end 108 | 109 | context 'if a default scope is configured' do 110 | let(:options) { { default_scope: :by_date } } 111 | let(:params) { {} } 112 | 113 | it 'returns the original scope chained with the default scope' do 114 | expect(result.chain).to eq [:by_date, [:asc]] 115 | end 116 | 117 | context 'if a default direction is configured' do 118 | let(:options) { { default_scope: :by_date, default_direction: :desc } } 119 | 120 | it 'returns the original scope chained with the default scope and default direction' do 121 | expect(result.chain).to eq [:by_date, [:desc]] 122 | end 123 | end 124 | end 125 | end 126 | 127 | describe '#is_scope_active?' do 128 | let(:params) { { 'sorting' => 'by_date' } } 129 | 130 | it 'returns true if the provided scope is the one currently active' do 131 | expect(sorting.is_scope_active?(:by_date)).to be_truthy 132 | end 133 | end 134 | end 135 | end 136 | end 137 | 138 | -------------------------------------------------------------------------------- /gemfiles/rails_40.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../ 3 | specs: 4 | admino (0.0.16) 5 | activemodel 6 | activesupport 7 | coercible 8 | showcase (~> 0.2.5) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | actionmailer (4.0.0) 14 | actionpack (= 4.0.0) 15 | mail (~> 2.5.3) 16 | actionpack (4.0.0) 17 | activesupport (= 4.0.0) 18 | builder (~> 3.1.0) 19 | erubis (~> 2.7.0) 20 | rack (~> 1.5.2) 21 | rack-test (~> 0.6.2) 22 | activemodel (4.0.0) 23 | activesupport (= 4.0.0) 24 | builder (~> 3.1.0) 25 | activerecord (4.0.0) 26 | activemodel (= 4.0.0) 27 | activerecord-deprecated_finders (~> 1.0.2) 28 | activesupport (= 4.0.0) 29 | arel (~> 4.0.0) 30 | activerecord-deprecated_finders (1.0.4) 31 | activesupport (4.0.0) 32 | i18n (~> 0.6, >= 0.6.4) 33 | minitest (~> 4.2) 34 | multi_json (~> 1.3) 35 | thread_safe (~> 0.1) 36 | tzinfo (~> 0.3.37) 37 | appraisal (2.0.1) 38 | activesupport (>= 3.2.21) 39 | bundler 40 | rake 41 | thor (>= 0.14.0) 42 | arel (4.0.2) 43 | builder (3.1.4) 44 | coderay (1.1.0) 45 | coercible (1.0.0) 46 | descendants_tracker (~> 0.0.1) 47 | coveralls (0.8.1) 48 | json (~> 1.8) 49 | rest-client (>= 1.6.8, < 2) 50 | simplecov (~> 0.10.0) 51 | term-ansicolor (~> 1.3) 52 | thor (~> 0.19.1) 53 | descendants_tracker (0.0.4) 54 | thread_safe (~> 0.3, >= 0.3.1) 55 | diff-lcs (1.2.5) 56 | docile (1.1.5) 57 | domain_name (0.5.24) 58 | unf (>= 0.0.5, < 1.0.0) 59 | erubis (2.7.0) 60 | hike (1.2.3) 61 | http-cookie (1.0.2) 62 | domain_name (~> 0.5) 63 | i18n (0.7.0) 64 | json (1.8.2) 65 | mail (2.5.4) 66 | mime-types (~> 1.16) 67 | treetop (~> 1.4.8) 68 | method_source (0.8.2) 69 | mime-types (1.25.1) 70 | mini_portile (0.6.2) 71 | minitest (4.7.5) 72 | multi_json (1.11.0) 73 | netrc (0.10.3) 74 | nokogiri (1.6.6.2) 75 | mini_portile (~> 0.6.0) 76 | polyglot (0.3.5) 77 | pry (0.10.1) 78 | coderay (~> 1.1.0) 79 | method_source (~> 0.8.1) 80 | slop (~> 3.4) 81 | rack (1.5.2) 82 | rack-test (0.6.3) 83 | rack (>= 1.0) 84 | rails (4.0.0) 85 | actionmailer (= 4.0.0) 86 | actionpack (= 4.0.0) 87 | activerecord (= 4.0.0) 88 | activesupport (= 4.0.0) 89 | bundler (>= 1.3.0, < 2.0) 90 | railties (= 4.0.0) 91 | sprockets-rails (~> 2.0.0) 92 | railties (4.0.0) 93 | actionpack (= 4.0.0) 94 | activesupport (= 4.0.0) 95 | rake (>= 0.8.7) 96 | thor (>= 0.18.1, < 2.0) 97 | rake (10.4.2) 98 | rest-client (1.8.0) 99 | http-cookie (>= 1.0.2, < 2.0) 100 | mime-types (>= 1.16, < 3.0) 101 | netrc (~> 0.7) 102 | rspec (3.2.0) 103 | rspec-core (~> 3.2.0) 104 | rspec-expectations (~> 3.2.0) 105 | rspec-mocks (~> 3.2.0) 106 | rspec-core (3.2.3) 107 | rspec-support (~> 3.2.0) 108 | rspec-expectations (3.2.1) 109 | diff-lcs (>= 1.2.0, < 2.0) 110 | rspec-support (~> 3.2.0) 111 | rspec-html-matchers (0.7.0) 112 | nokogiri (~> 1) 113 | rspec (~> 3) 114 | rspec-mocks (3.2.1) 115 | diff-lcs (>= 1.2.0, < 2.0) 116 | rspec-support (~> 3.2.0) 117 | rspec-support (3.2.2) 118 | showcase (0.2.5) 119 | activesupport 120 | simple_form (3.0.3) 121 | actionpack (~> 4.0) 122 | activemodel (~> 4.0) 123 | simplecov (0.10.0) 124 | docile (~> 1.1.0) 125 | json (~> 1.8) 126 | simplecov-html (~> 0.10.0) 127 | simplecov-html (0.10.0) 128 | slop (3.6.0) 129 | sprockets (2.12.3) 130 | hike (~> 1.2) 131 | multi_json (~> 1.0) 132 | rack (~> 1.0) 133 | tilt (~> 1.1, != 1.3.0) 134 | sprockets-rails (2.0.1) 135 | actionpack (>= 3.0) 136 | activesupport (>= 3.0) 137 | sprockets (~> 2.8) 138 | term-ansicolor (1.3.0) 139 | tins (~> 1.0) 140 | thor (0.19.1) 141 | thread_safe (0.3.5) 142 | tilt (1.4.1) 143 | tins (1.5.1) 144 | treetop (1.4.15) 145 | polyglot 146 | polyglot (>= 0.3.1) 147 | tzinfo (0.3.44) 148 | unf (0.1.4) 149 | unf_ext 150 | unf_ext (0.0.7.1) 151 | 152 | PLATFORMS 153 | ruby 154 | 155 | DEPENDENCIES 156 | actionpack 157 | admino! 158 | appraisal 159 | bundler (~> 1.3) 160 | coveralls 161 | i18n 162 | pry 163 | rails (= 4.0.0) 164 | rake 165 | rspec 166 | rspec-html-matchers 167 | simple_form (= 3.0.3) 168 | 169 | BUNDLED WITH 170 | 1.10.6 171 | -------------------------------------------------------------------------------- /spec/admino/table/head_row_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Admino 4 | module Table 5 | describe HeadRow do 6 | subject(:row) { HeadRow.new(klass, query, view) } 7 | let(:klass) { Post } 8 | let(:query) { nil } 9 | let(:view) { RailsViewContext.new } 10 | 11 | it 'takes a class and a view context' do 12 | expect(row.resource_klass).to eq klass 13 | expect(row.view_context).to eq view 14 | end 15 | 16 | describe '#column' do 17 | subject { row.to_html } 18 | 19 | context 'text' do 20 | context 'with string label' do 21 | before do 22 | row.column(:title, 'This is a title') 23 | end 24 | 25 | it 'generates a label with it' do 26 | is_expected.to have_tag(:th, text: 'This is a title') 27 | end 28 | end 29 | 30 | context 'with symbol label and I18n set up' do 31 | before do 32 | I18n.backend.store_translations( 33 | :en, 34 | activemodel: { attributes: { post: { foo: 'This is foo' } } } 35 | ) 36 | end 37 | 38 | before do 39 | row.column(:title, :foo) 40 | end 41 | 42 | it 'generates a label with the human attribute name' do 43 | is_expected.to have_tag(:th, text: 'This is foo') 44 | end 45 | end 46 | 47 | context 'with no label' do 48 | before { row.column(:title) } 49 | 50 | it 'generates a label with the titleized attribute name' do 51 | is_expected.to have_tag(:th, text: 'Title') 52 | end 53 | 54 | context 'with I18n set up' do 55 | before do 56 | I18n.backend.store_translations( 57 | :en, 58 | activemodel: { attributes: { post: { title: 'Post title' } } } 59 | ) 60 | end 61 | 62 | before { row.column(:title) } 63 | 64 | it 'generates a label with the human attribute name' do 65 | is_expected.to have_tag(:th, text: 'Post title') 66 | end 67 | end 68 | end 69 | end 70 | 71 | context 'sorting' do 72 | context 'if no query object is present' do 73 | it 'raises an ArgumentError' do 74 | expect { row.column(:title, sorting: :by_title) }.to raise_error ArgumentError 75 | end 76 | end 77 | 78 | context 'else' do 79 | let(:query) { double('Query', sorting: sorting) } 80 | let(:sorting) { double('Sorting') } 81 | 82 | before do 83 | allow(sorting).to receive(:scope_link).with(:by_title, 'Title', {}). 84 | and_return('Link') 85 | end 86 | 87 | before { row.column(:title, sorting: :by_title) } 88 | 89 | it 'generates a sorting scope link' do 90 | is_expected.to have_tag(:th, text: 'Link') 91 | end 92 | end 93 | end 94 | 95 | context 'role' do 96 | before { row.column(:author_name) } 97 | 98 | it 'generates a role attribute with the snake-cased name of the attribute' do 99 | is_expected.to have_tag(:th, with: { role: 'author-name' }) 100 | end 101 | end 102 | 103 | context 'with html options param' do 104 | before { row.column(:title, class: 'foo') } 105 | 106 | it 'uses it to build attributes' do 107 | is_expected.to have_tag(:th, with: { class: 'foo' }) 108 | end 109 | end 110 | end 111 | 112 | describe '#actions' do 113 | subject { row.to_html } 114 | 115 | context do 116 | before { row.actions } 117 | 118 | it 'renders a th cell with role "actions"' do 119 | is_expected.to have_tag(:th, with: { role: 'actions' }) 120 | end 121 | 122 | it 'renders a th cell with text "Actions"' do 123 | is_expected.to have_tag(:th, text: 'Actions') 124 | end 125 | end 126 | 127 | context 'with generic I18n set up' do 128 | before do 129 | I18n.backend.store_translations( 130 | :en, 131 | table: { actions: { title: 'Available actions' } } 132 | ) 133 | end 134 | 135 | it 'renders a th cell with I18n text' do 136 | row.actions 137 | is_expected.to have_tag(:th, text: 'Available actions') 138 | end 139 | 140 | context 'and specific I18n set up' do 141 | before do 142 | I18n.backend.store_translations( 143 | :en, 144 | table: { actions: { post: { title: 'Post actions' } } } 145 | ) 146 | end 147 | 148 | it 'uses the specific I18n text' do 149 | row.actions 150 | is_expected.to have_tag(:th, text: 'Post actions') 151 | end 152 | end 153 | end 154 | end 155 | end 156 | end 157 | end 158 | 159 | -------------------------------------------------------------------------------- /gemfiles/rails_42.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../ 3 | specs: 4 | admino (0.0.16) 5 | activemodel 6 | activesupport 7 | coercible 8 | showcase (~> 0.2.5) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | actionmailer (4.2.1) 14 | actionpack (= 4.2.1) 15 | actionview (= 4.2.1) 16 | activejob (= 4.2.1) 17 | mail (~> 2.5, >= 2.5.4) 18 | rails-dom-testing (~> 1.0, >= 1.0.5) 19 | actionpack (4.2.1) 20 | actionview (= 4.2.1) 21 | activesupport (= 4.2.1) 22 | rack (~> 1.6) 23 | rack-test (~> 0.6.2) 24 | rails-dom-testing (~> 1.0, >= 1.0.5) 25 | rails-html-sanitizer (~> 1.0, >= 1.0.1) 26 | actionview (4.2.1) 27 | activesupport (= 4.2.1) 28 | builder (~> 3.1) 29 | erubis (~> 2.7.0) 30 | rails-dom-testing (~> 1.0, >= 1.0.5) 31 | rails-html-sanitizer (~> 1.0, >= 1.0.1) 32 | activejob (4.2.1) 33 | activesupport (= 4.2.1) 34 | globalid (>= 0.3.0) 35 | activemodel (4.2.1) 36 | activesupport (= 4.2.1) 37 | builder (~> 3.1) 38 | activerecord (4.2.1) 39 | activemodel (= 4.2.1) 40 | activesupport (= 4.2.1) 41 | arel (~> 6.0) 42 | activesupport (4.2.1) 43 | i18n (~> 0.7) 44 | json (~> 1.7, >= 1.7.7) 45 | minitest (~> 5.1) 46 | thread_safe (~> 0.3, >= 0.3.4) 47 | tzinfo (~> 1.1) 48 | appraisal (2.0.1) 49 | activesupport (>= 3.2.21) 50 | bundler 51 | rake 52 | thor (>= 0.14.0) 53 | arel (6.0.0) 54 | builder (3.2.2) 55 | coderay (1.1.0) 56 | coercible (1.0.0) 57 | descendants_tracker (~> 0.0.1) 58 | coveralls (0.8.1) 59 | json (~> 1.8) 60 | rest-client (>= 1.6.8, < 2) 61 | simplecov (~> 0.10.0) 62 | term-ansicolor (~> 1.3) 63 | thor (~> 0.19.1) 64 | descendants_tracker (0.0.4) 65 | thread_safe (~> 0.3, >= 0.3.1) 66 | diff-lcs (1.2.5) 67 | docile (1.1.5) 68 | domain_name (0.5.24) 69 | unf (>= 0.0.5, < 1.0.0) 70 | erubis (2.7.0) 71 | globalid (0.3.5) 72 | activesupport (>= 4.1.0) 73 | http-cookie (1.0.2) 74 | domain_name (~> 0.5) 75 | i18n (0.7.0) 76 | json (1.8.2) 77 | loofah (2.0.1) 78 | nokogiri (>= 1.5.9) 79 | mail (2.6.3) 80 | mime-types (>= 1.16, < 3) 81 | method_source (0.8.2) 82 | mime-types (2.5) 83 | mini_portile (0.6.2) 84 | minitest (5.6.1) 85 | netrc (0.10.3) 86 | nokogiri (1.6.6.2) 87 | mini_portile (~> 0.6.0) 88 | pry (0.10.1) 89 | coderay (~> 1.1.0) 90 | method_source (~> 0.8.1) 91 | slop (~> 3.4) 92 | rack (1.6.0) 93 | rack-test (0.6.3) 94 | rack (>= 1.0) 95 | rails (4.2.1) 96 | actionmailer (= 4.2.1) 97 | actionpack (= 4.2.1) 98 | actionview (= 4.2.1) 99 | activejob (= 4.2.1) 100 | activemodel (= 4.2.1) 101 | activerecord (= 4.2.1) 102 | activesupport (= 4.2.1) 103 | bundler (>= 1.3.0, < 2.0) 104 | railties (= 4.2.1) 105 | sprockets-rails 106 | rails-deprecated_sanitizer (1.0.3) 107 | activesupport (>= 4.2.0.alpha) 108 | rails-dom-testing (1.0.6) 109 | activesupport (>= 4.2.0.beta, < 5.0) 110 | nokogiri (~> 1.6.0) 111 | rails-deprecated_sanitizer (>= 1.0.1) 112 | rails-html-sanitizer (1.0.2) 113 | loofah (~> 2.0) 114 | railties (4.2.1) 115 | actionpack (= 4.2.1) 116 | activesupport (= 4.2.1) 117 | rake (>= 0.8.7) 118 | thor (>= 0.18.1, < 2.0) 119 | rake (10.4.2) 120 | rest-client (1.8.0) 121 | http-cookie (>= 1.0.2, < 2.0) 122 | mime-types (>= 1.16, < 3.0) 123 | netrc (~> 0.7) 124 | rspec (3.2.0) 125 | rspec-core (~> 3.2.0) 126 | rspec-expectations (~> 3.2.0) 127 | rspec-mocks (~> 3.2.0) 128 | rspec-core (3.2.3) 129 | rspec-support (~> 3.2.0) 130 | rspec-expectations (3.2.1) 131 | diff-lcs (>= 1.2.0, < 2.0) 132 | rspec-support (~> 3.2.0) 133 | rspec-html-matchers (0.7.0) 134 | nokogiri (~> 1) 135 | rspec (~> 3) 136 | rspec-mocks (3.2.1) 137 | diff-lcs (>= 1.2.0, < 2.0) 138 | rspec-support (~> 3.2.0) 139 | rspec-support (3.2.2) 140 | showcase (0.2.5) 141 | activesupport 142 | simple_form (3.1.0) 143 | actionpack (~> 4.0) 144 | activemodel (~> 4.0) 145 | simplecov (0.10.0) 146 | docile (~> 1.1.0) 147 | json (~> 1.8) 148 | simplecov-html (~> 0.10.0) 149 | simplecov-html (0.10.0) 150 | slop (3.6.0) 151 | sprockets (3.0.3) 152 | rack (~> 1.0) 153 | sprockets-rails (2.2.4) 154 | actionpack (>= 3.0) 155 | activesupport (>= 3.0) 156 | sprockets (>= 2.8, < 4.0) 157 | term-ansicolor (1.3.0) 158 | tins (~> 1.0) 159 | thor (0.19.1) 160 | thread_safe (0.3.5) 161 | tins (1.5.1) 162 | tzinfo (1.2.2) 163 | thread_safe (~> 0.1) 164 | unf (0.1.4) 165 | unf_ext 166 | unf_ext (0.0.7.1) 167 | 168 | PLATFORMS 169 | ruby 170 | 171 | DEPENDENCIES 172 | actionpack 173 | admino! 174 | appraisal 175 | bundler (~> 1.3) 176 | coveralls 177 | i18n 178 | pry 179 | rails (= 4.2.1) 180 | rake 181 | rspec 182 | rspec-html-matchers 183 | simple_form (= 3.1.0) 184 | 185 | BUNDLED WITH 186 | 1.10.6 187 | -------------------------------------------------------------------------------- /spec/admino/query/filter_group_presenter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'active_support/hash_with_indifferent_access' 3 | 4 | module Admino 5 | module Query 6 | describe FilterGroupPresenter do 7 | subject(:presenter) { FilterGroupPresenter.new(filter_group, view) } 8 | let(:view) { RailsViewContext.new } 9 | let(:filter_group) do 10 | double( 11 | 'FilterGroup', 12 | query_i18n_key: :query_name, 13 | i18n_key: :filter_group, 14 | param_name: :filter_group 15 | ) 16 | end 17 | let(:request_object) do 18 | double( 19 | 'ActionDispatch::Request', 20 | query_parameters: ActiveSupport::HashWithIndifferentAccess.new( 21 | params 22 | ), 23 | path: '/' 24 | ) 25 | end 26 | let(:params) do 27 | { 'query' => { 'field' => 'value', 'filter_group' => 'bar' } } 28 | end 29 | 30 | before do 31 | allow(view).to receive(:request).and_return(request_object) 32 | end 33 | 34 | describe '#scope_link' do 35 | subject { presenter.scope_link(:foo) } 36 | let(:scope_active) { false } 37 | 38 | before do 39 | allow(filter_group).to receive(:is_scope_active?). 40 | with(:foo).and_return(scope_active) 41 | end 42 | 43 | context 'active CSS class' do 44 | let(:scope_active) { true } 45 | 46 | it 'adds an is-active class' do 47 | is_expected.to have_tag(:a, with: { class: 'is-active' }) 48 | end 49 | 50 | context 'if an :active_class option is specified' do 51 | subject { presenter.scope_link(:foo, active_class: 'active') } 52 | 53 | it 'adds it' do 54 | is_expected.to have_tag(:a, with: { class: 'active' }) 55 | end 56 | end 57 | end 58 | 59 | context 'else' do 60 | it 'does not add it' do 61 | is_expected.not_to have_tag(:a, with: { class: 'is-active' }) 62 | end 63 | end 64 | 65 | context 'label' do 66 | before do 67 | allow(presenter).to receive(:scope_name).with(:foo).and_return('scope_name') 68 | end 69 | 70 | it 'uses #scope_name method' do 71 | is_expected.to have_tag(:a, text: 'scope_name') 72 | end 73 | 74 | context 'if a second parameter is supplied' do 75 | subject { presenter.scope_link(:foo, 'test', active_class: 'active') } 76 | 77 | it 'uses it' do 78 | is_expected.to have_tag(:a, text: 'test') 79 | end 80 | end 81 | end 82 | 83 | context 'URL' do 84 | before do 85 | allow(presenter).to receive(:scope_path).with(:foo).and_return('URL') 86 | end 87 | 88 | it 'uses #scope_path method' do 89 | is_expected.to have_tag(:a, href: 'URL') 90 | end 91 | end 92 | end 93 | 94 | describe '#scope_params' do 95 | let(:scope_active) { false } 96 | subject { presenter.scope_params(:foo) } 97 | 98 | before do 99 | allow(filter_group).to receive(:is_scope_active?).with(:foo).and_return(scope_active) 100 | end 101 | 102 | context 'if scope is active' do 103 | let(:scope_active) { true } 104 | 105 | it 'deletes the filter_group param' do 106 | expect(subject[:query]).not_to have_key 'filter_group' 107 | end 108 | 109 | it 'keeps the request parameters intact' do 110 | presenter.scope_params(:foo) 111 | expect(request_object.query_parameters[:query][:filter_group]).to be_present 112 | end 113 | 114 | context 'the resulting query hash becomes empty' do 115 | let(:params) do 116 | { 'query' => { 'filter_group' => 'bar' } } 117 | end 118 | 119 | it 'removes the param altoghether' do 120 | expect(subject).not_to have_key 'query' 121 | end 122 | end 123 | end 124 | 125 | context 'else' do 126 | let(:scope_active) { false } 127 | 128 | it 'is set as filter group value' do 129 | expect(subject[:query][:filter_group]).to eq 'foo' 130 | end 131 | end 132 | 133 | it 'preserves the other params' do 134 | expect(subject[:query][:field]).to eq 'value' 135 | end 136 | end 137 | 138 | describe '#name' do 139 | context do 140 | before do 141 | I18n.backend.store_translations( 142 | :en, 143 | query: { filter_groups: { query_name: { filter_group: { name: 'NAME' } } } } 144 | ) 145 | end 146 | 147 | it 'returns a I18n translatable name for the filter_group' do 148 | expect(presenter.name).to eq 'NAME' 149 | end 150 | end 151 | 152 | context 'if no translation is available' do 153 | it 'falls back to a titleized version of the filter_group name' do 154 | expect(presenter.name).to eq 'Filter group' 155 | end 156 | end 157 | end 158 | 159 | describe '#scope_name' do 160 | context do 161 | before do 162 | I18n.backend.store_translations( 163 | :en, 164 | query: { filter_groups: { query_name: { filter_group: { scopes: { bar: 'NAME' } } } } } 165 | ) 166 | end 167 | 168 | it 'returns a I18n translatable name for the scope' do 169 | expect(presenter.scope_name(:bar)).to eq 'NAME' 170 | end 171 | end 172 | 173 | context 'if no translation is available' do 174 | it 'falls back to a titleized version of the scope name' do 175 | expect(presenter.scope_name(:bar)).to eq 'Bar' 176 | end 177 | end 178 | end 179 | end 180 | end 181 | end 182 | 183 | -------------------------------------------------------------------------------- /spec/admino/query/base_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Admino 4 | module Query 5 | describe Base do 6 | subject(:query) { Base.new(params, context, config) } 7 | let(:params) { {} } 8 | let(:context) { {} } 9 | let(:config) { nil } 10 | 11 | it 'takes a request params' do 12 | expect(query.params).to eq params 13 | end 14 | 15 | context 'with an explicit config' do 16 | let(:config) { Configuration.new } 17 | 18 | it 'uses it' do 19 | expect(query.config).to eq config 20 | end 21 | end 22 | 23 | context 'with a declared search_field' do 24 | let(:config) { Configuration.new } 25 | let(:search_field_config) { config.add_search_field(:search_field) } 26 | 27 | before do 28 | search_field_config 29 | end 30 | 31 | it 'returns a configured SearchField' do 32 | search_field = query.search_field_by_name(:search_field) 33 | expect(search_field.config).to eq search_field_config 34 | expect(search_field.params).to eq params 35 | end 36 | end 37 | 38 | context 'with a declared filter_group' do 39 | let(:config) { Configuration.new } 40 | let(:filter_group_config) { config.add_filter_group(:filter_group, [:one, :two]) } 41 | 42 | before do 43 | filter_group_config 44 | end 45 | 46 | it 'returns a configured FilterGroup' do 47 | filter_group = query.filter_group_by_name(:filter_group) 48 | expect(filter_group.config).to eq filter_group_config 49 | expect(filter_group.params).to eq params 50 | expect(filter_group.i18n_key).to eq :filter_group 51 | end 52 | end 53 | 54 | context 'with a declared sorting' do 55 | let(:config) { Configuration.new } 56 | let(:sorting_config) do 57 | config.add_sorting_scopes([:by_title, :by_date]) 58 | end 59 | 60 | before do 61 | sorting_config 62 | end 63 | 64 | it 'returns a configured Sorting' do 65 | sorting = query.sorting 66 | expect(sorting.config).to eq sorting_config 67 | expect(sorting.params).to eq params 68 | end 69 | end 70 | 71 | describe '#scope' do 72 | let(:config) { Configuration.new } 73 | let(:result) { query.scope(starting_scope) } 74 | let(:starting_scope) { ScopeMock.new('explicit') } 75 | 76 | describe 'starting scope' do 77 | context 'with an explicit scope' do 78 | it 'uses it' do 79 | expect(result.name).to eq 'explicit' 80 | end 81 | end 82 | 83 | context 'with no explicit scope, but a default one configured' do 84 | let(:result) { query.scope } 85 | 86 | before do 87 | config.starting_scope_callable = Proc.new { |query| 88 | ScopeMock.new('configured').foo(query) 89 | } 90 | end 91 | 92 | before do 93 | result 94 | end 95 | 96 | it 'calls it with self and uses it' do 97 | expect(result.name).to eq 'configured' 98 | expect(result.chain).to eq [:foo, [query]] 99 | end 100 | end 101 | 102 | context 'with no scope' do 103 | let(:result) { query.scope } 104 | 105 | it 'raises a ArgumentError' do 106 | expect { result }.to raise_error(ArgumentError) 107 | end 108 | end 109 | end 110 | 111 | context 'with a set of search_fields, filter_groups and sortings' do 112 | let(:search_field_config) { config.add_search_field(:search_field) } 113 | let(:filter_group_config) { config.add_filter_group(:filter_group, [:one, :two]) } 114 | let(:sorting_config) { config.add_sorting_scopes([:title, :year]) } 115 | 116 | let(:params) do 117 | { 118 | query: { 119 | search_field: "foo", 120 | filter_group: "one", 121 | }, 122 | sorting: "title", 123 | sort_order: "desc" 124 | } 125 | end 126 | 127 | before do 128 | search_field_config 129 | filter_group_config 130 | sorting_config 131 | query 132 | end 133 | 134 | context 'if query object does not respond to scopes' do 135 | it 'chains from starting scope' do 136 | expect(result.chain).to eq [ 137 | :search_field, ["foo"], 138 | :one, [], 139 | :title, [:desc] 140 | ] 141 | end 142 | end 143 | 144 | context 'else' do 145 | let(:query_klass) do 146 | Class.new(Base) do 147 | def self.model_name 148 | ActiveModel::Name.new(self, nil, "temp") 149 | end 150 | 151 | def search_field_scope(scope, foo) 152 | scope.my_search_field(foo) 153 | end 154 | 155 | def one_scope(scope) 156 | scope.my_one 157 | end 158 | 159 | def title_scope(scope, order) 160 | scope.my_title(order) 161 | end 162 | end 163 | end 164 | 165 | subject(:query) do 166 | query_klass.new(params, context, config) 167 | end 168 | 169 | it 'chains from starting scope calling query object methods' do 170 | expect(result.chain).to eq [ 171 | :my_search_field, ["foo"], 172 | :my_one, [], 173 | :my_title, [:desc] 174 | ] 175 | end 176 | end 177 | end 178 | 179 | context 'with a configured ending scope' do 180 | before do 181 | config.ending_scope_callable = Proc.new { |query| bar(query) } 182 | end 183 | 184 | it 'calls it with self at the end of the chain' do 185 | expect(result.chain).to eq [:bar, [query]] 186 | end 187 | end 188 | end 189 | end 190 | end 191 | end 192 | 193 | -------------------------------------------------------------------------------- /spec/admino/table/presenter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'ostruct' 3 | 4 | module Admino 5 | module Table 6 | describe Presenter do 7 | subject(:presenter) { presenter_klass.new(collection, Post, query, view) } 8 | let(:presenter_klass) { Presenter } 9 | let(:query) { double('Query') } 10 | let(:view) { RailsViewContext.new } 11 | 12 | let(:collection) { [ first_post, second_post ] } 13 | let(:first_post) { Post.new('1') } 14 | let(:second_post) { Post.new('2') } 15 | 16 | let(:head_row) { double('HeadRow', to_html: ''.html_safe) } 17 | let(:resource_row) { double('ResourceRow', to_html: ''.html_safe) } 18 | 19 | before do 20 | allow(HeadRow).to receive(:new).with(Post, query, view).and_return(head_row) 21 | allow(ResourceRow).to receive(:new).with(first_post, view).and_return(resource_row) 22 | allow(ResourceRow).to receive(:new).with(second_post, view).and_return(resource_row) 23 | end 24 | 25 | describe '#to_html' do 26 | let(:result) { presenter.to_html } 27 | 28 | it 'outputs a table' do 29 | expect(result).to have_tag(:table) 30 | end 31 | 32 | it 'outputs a thead with a single row' do 33 | expect(result).to have_tag('table thead tr') 34 | end 35 | 36 | it 'outputs a tbody with a row for each collection member' do 37 | expect(result).to have_tag('table tbody tr', count: 2) 38 | end 39 | 40 | context 'if the record responds to #dom_id' do 41 | before do 42 | allow(first_post).to receive(:dom_id).and_return('post_1') 43 | allow(second_post).to receive(:dom_id).and_return('post_2') 44 | end 45 | 46 | it 'adds a record identifier to each collection row' do 47 | expect(result).to have_tag('tbody tr#post_1:first-child') 48 | expect(result).to have_tag('tbody tr#post_2:last-child') 49 | end 50 | end 51 | 52 | it 'adds zebra classes to each collection row' do 53 | expect(result).to have_tag('tbody tr.is-even:first-child') 54 | expect(result).to have_tag('tbody tr.is-odd:last-child') 55 | end 56 | 57 | it 'delegates thead columns creation to .to_html HeadRow' do 58 | expect(result).to have_tag('thead tr td#thead_td') 59 | end 60 | 61 | it 'delegates tbody columns creation to .to_html ResourceRow' do 62 | expect(result).to have_tag('tbody tr td#tbody_td') 63 | end 64 | 65 | it 'allows passing table HTML options' do 66 | expect(presenter.to_html(id: 'table')).to have_tag(:table, with: { id: 'table' }) 67 | end 68 | 69 | context 'with a block' do 70 | let(:block_call_args) do 71 | block_call_args = [] 72 | presenter.to_html do |*args| 73 | block_call_args << args 74 | end 75 | block_call_args 76 | end 77 | 78 | it 'calls it once passing the HeadRow instance' do 79 | expect(block_call_args[0]).to eq [head_row, nil] 80 | end 81 | 82 | it 'calls it once for each collection member passing the ResourceRow instance and the member itself' do 83 | expect(block_call_args[1]).to eq [resource_row, first_post] 84 | expect(block_call_args[2]).to eq [resource_row, second_post] 85 | end 86 | end 87 | 88 | context 'custom table HTML options' do 89 | let(:presenter_klass) do 90 | Class.new(Presenter) do 91 | private 92 | 93 | def table_html_options 94 | { id: 'table' } 95 | end 96 | 97 | def thead_html_options 98 | { id: 'thead' } 99 | end 100 | 101 | def thead_tr_html_options 102 | { id: 'thead_tr' } 103 | end 104 | 105 | def tbody_html_options 106 | { id: 'tbody' } 107 | end 108 | 109 | def tbody_tr_html_options(resource, index) 110 | { class: "index-#{index}" } 111 | end 112 | 113 | def zebra_css_classes 114 | %w(one two) 115 | end 116 | end 117 | end 118 | 119 | it "allows customizing the default table html attributes" do 120 | expect(presenter.to_html).to have_tag(:table, with: { id: 'table' }) 121 | end 122 | 123 | it "allows customizing the the default thead html attributes" do 124 | expect(presenter.to_html).to have_tag(:thead, with: { id: 'thead' }) 125 | end 126 | 127 | it "allows customizing the the default thead_tr html attributes" do 128 | expect(presenter.to_html).to have_tag('thead tr#thead_tr') 129 | end 130 | 131 | it "allows customizing the the default tbody html attributes" do 132 | expect(presenter.to_html).to have_tag(:tbody, with: { id: 'tbody' }) 133 | end 134 | 135 | it "allows customizing the tbody_tr html attributes" do 136 | expect(presenter.to_html).to have_tag("tbody tr.index-0:first-child") 137 | end 138 | 139 | it 'allows customizing zebra classes' do 140 | expect(presenter.to_html).to have_tag("tbody tr.one:first-child") 141 | end 142 | end 143 | 144 | context 'custom row builders' do 145 | let(:presenter_klass) do 146 | Class.new(Presenter) do 147 | private 148 | 149 | def head_row(*args) 150 | OpenStruct.new(to_html: ''.html_safe) 151 | end 152 | 153 | def resource_row(*args) 154 | OpenStruct.new(to_html: ''.html_safe) 155 | end 156 | end 157 | end 158 | 159 | it 'allows customizing head row renderers' do 160 | expect(presenter.to_html).to have_tag('thead tr td#custom_thead_td') 161 | end 162 | 163 | it 'allows customizing resource row renderers' do 164 | expect(presenter.to_html).to have_tag('tbody tr td#custom_tbody_td') 165 | end 166 | end 167 | 168 | context 'with empty collection and no collection_klass' do 169 | let(:collection) { [] } 170 | subject(:presenter) { presenter_klass.new(collection, nil, query, view) } 171 | 172 | it 'raises an Exception' do 173 | expect { result }.to raise_error(ArgumentError) 174 | end 175 | end 176 | end 177 | end 178 | end 179 | end 180 | 181 | -------------------------------------------------------------------------------- /spec/admino/query/sorting_presenter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'active_support/hash_with_indifferent_access' 3 | 4 | module Admino 5 | module Query 6 | describe SortingPresenter do 7 | subject(:presenter) { SortingPresenter.new(sorting, view) } 8 | let(:view) { RailsViewContext.new } 9 | let(:sorting) do 10 | double( 11 | 'Sorting', 12 | default_scope: 'by_name', 13 | query_i18n_key: 'query_name' 14 | ) 15 | end 16 | let(:request_object) do 17 | double( 18 | 'ActionDispatch::Request', 19 | query_parameters: ActiveSupport::HashWithIndifferentAccess.new( 20 | 'sorting' => 'by_date', 'other' => 'value' 21 | ), 22 | path: '/' 23 | ) 24 | end 25 | 26 | before do 27 | allow(view).to receive(:request).and_return(request_object) 28 | end 29 | 30 | describe '#scope_link' do 31 | subject { presenter.scope_link(:by_title, 'Titolo') } 32 | 33 | before do 34 | allow(sorting).to receive(:is_scope_active?).with(:by_title).and_return(false) 35 | end 36 | 37 | context 'scope is active' do 38 | before do 39 | allow(sorting).to receive(:is_scope_active?).with(:by_title).and_return(true) 40 | end 41 | 42 | context 'ascending' do 43 | before do 44 | allow(sorting).to receive(:ascending?).and_return(true) 45 | end 46 | 47 | it 'adds an is-asc class' do 48 | is_expected.to have_tag(:a, with: { class: 'is-asc' }) 49 | end 50 | 51 | context 'if an :asc_class option is specified' do 52 | subject { presenter.scope_link(:by_title, 'Titolo', asc_class: 'asc') } 53 | 54 | it 'adds it' do 55 | is_expected.to have_tag(:a, with: { class: 'asc' }) 56 | end 57 | end 58 | end 59 | 60 | context 'descendent' do 61 | before do 62 | allow(sorting).to receive(:ascending?).and_return(false) 63 | end 64 | 65 | it 'adds an is-desc class' do 66 | is_expected.to have_tag(:a, with: { class: 'is-desc' }) 67 | end 68 | 69 | context 'if a :desc_class option is specified' do 70 | subject { presenter.scope_link(:by_title, 'Titolo', desc_class: 'desc') } 71 | 72 | it 'adds it' do 73 | is_expected.to have_tag(:a, with: { class: 'desc' }) 74 | end 75 | end 76 | end 77 | end 78 | 79 | context 'else' do 80 | it 'does not add it' do 81 | is_expected.not_to have_tag(:a, with: { class: 'is-asc' }) 82 | end 83 | end 84 | 85 | context 'label' do 86 | it 'uses the provided argument' do 87 | is_expected.to have_tag(:a, text: 'Titolo') 88 | end 89 | end 90 | 91 | context 'URL' do 92 | before do 93 | allow(presenter).to receive(:scope_path).with(:by_title).and_return('URL') 94 | end 95 | 96 | it 'uses #scope_path method' do 97 | is_expected.to have_tag(:a, href: 'URL') 98 | end 99 | end 100 | end 101 | 102 | describe '#scope_params' do 103 | subject { presenter.scope_params(:by_title) } 104 | 105 | before do 106 | allow(sorting).to receive(:is_scope_active?).with(:by_title).and_return(false) 107 | end 108 | 109 | it 'preserves other params' do 110 | expect(subject[:other]).to eq 'value' 111 | end 112 | 113 | it 'keeps the request parameters intact' do 114 | subject 115 | expect(request_object.query_parameters[:sorting]).to eq 'by_date' 116 | end 117 | 118 | it 'sets the sorting param as the scope' do 119 | expect(subject[:sorting]).to eq 'by_title' 120 | end 121 | 122 | context 'scope is active' do 123 | before do 124 | allow(sorting).to receive(:is_scope_active?).with(:by_title).and_return(true) 125 | end 126 | 127 | context 'is currently ascending' do 128 | before do 129 | allow(sorting).to receive(:ascending?).and_return(true) 130 | end 131 | 132 | it 'sets the sorting order to descending' do 133 | expect(subject[:sort_order]).to eq 'desc' 134 | end 135 | end 136 | 137 | context 'is currently descending' do 138 | before do 139 | allow(sorting).to receive(:ascending?).and_return(false) 140 | end 141 | 142 | it 'sets the sorting order to ascending' do 143 | expect(subject[:sort_order]).to eq 'asc' 144 | end 145 | end 146 | end 147 | 148 | context 'scope is the default one' do 149 | let(:sorting) { double('Sorting', default_scope: :by_title) } 150 | 151 | context 'default scope is ascending' do 152 | before do 153 | allow(sorting).to receive(:default_direction).and_return(:asc) 154 | end 155 | 156 | it 'sets the sorting order to ascending' do 157 | expect(subject[:sort_order]).to eq 'asc' 158 | end 159 | end 160 | 161 | context 'default scope is descending' do 162 | before do 163 | allow(sorting).to receive(:default_direction).and_return(:desc) 164 | end 165 | 166 | it 'sets the sorting order to descending' do 167 | expect(subject[:sort_order]).to eq 'desc' 168 | end 169 | end 170 | end 171 | 172 | context 'else' do 173 | before do 174 | allow(sorting).to receive(:is_scope_active?).with(:by_title).and_return(false) 175 | end 176 | 177 | it 'sets the sorting order to ascending' do 178 | expect(subject[:sort_order]).to eq 'asc' 179 | end 180 | end 181 | end 182 | 183 | describe '#scope_name' do 184 | context do 185 | before do 186 | I18n.backend.store_translations( 187 | :en, 188 | query: { sorting_scopes: { query_name: { by_name: 'Sort by name' } } } 189 | ) 190 | end 191 | 192 | it 'returns a I18n translatable name for the scope' do 193 | expect(presenter.scope_name(:by_name)).to eq 'Sort by name' 194 | end 195 | end 196 | 197 | context 'if no translation is available' do 198 | it 'falls back to a titleized version of the scope name' do 199 | expect(presenter.scope_name(:by_name)).to eq 'By name' 200 | end 201 | end 202 | end 203 | end 204 | end 205 | end 206 | 207 | -------------------------------------------------------------------------------- /spec/admino/table/resource_row_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Admino 4 | module Table 5 | describe ResourceRow do 6 | subject(:row) { ResourceRow.new(resource, view) } 7 | let(:view) { RailsViewContext.new } 8 | let(:resource) { Post.new('1') } 9 | 10 | it 'takes a resource and a view context' do 11 | expect(row.resource).to eq resource 12 | expect(row.view_context).to eq view 13 | end 14 | 15 | describe '#column' do 16 | subject { row.to_html } 17 | 18 | context 'if block is present' do 19 | before do 20 | row.column { 'foo' } 21 | end 22 | 23 | it 'fills the cell with the block content' do 24 | is_expected.to have_tag(:td, text: 'foo') 25 | end 26 | end 27 | 28 | context 'if attribute is present' do 29 | before { row.column(:title) } 30 | 31 | it 'fills the cell with the attribute value' do 32 | is_expected.to have_tag(:td, text: 'Post 1') 33 | end 34 | end 35 | 36 | context 'if both attribute and block are missing' do 37 | it 'raises an ArgumentError' do 38 | expect { row.column('Title') }.to raise_error(ArgumentError) 39 | end 40 | end 41 | 42 | context 'role attribute' do 43 | before { row.column(:author_name) } 44 | 45 | it 'generates a role attribute with the snake-cased name of the attribute' do 46 | is_expected.to have_tag(:td, with: { role: 'author-name' }) 47 | end 48 | end 49 | 50 | context 'with HTML options param' do 51 | before { row.column(:title, class: 'title') } 52 | 53 | it 'uses it to build attributes' do 54 | is_expected.to have_tag(:td, with: { class: 'title' }) 55 | end 56 | 57 | context 'with a class that implements a _html_options' do 58 | let(:row) { row_subclass.new(resource, view) } 59 | let(:row_subclass) do 60 | Class.new(ResourceRow) do 61 | def column_html_options(action_name) 62 | { class: 'attribute' } 63 | end 64 | end 65 | end 66 | 67 | it 'renders them as attributes' do 68 | is_expected.to have_tag(:td, with: { class: 'attribute title' }) 69 | end 70 | end 71 | end 72 | end 73 | 74 | describe '#actions' do 75 | context 'block given' do 76 | it 'yields the block' do 77 | called = false 78 | result = row.actions do 79 | called = true 80 | end 81 | 82 | expect(called).to be_truthy 83 | end 84 | end 85 | 86 | context 'no block' do 87 | before do 88 | allow(row).to receive(:action) 89 | end 90 | 91 | before do 92 | row.actions(:show, :destroy) 93 | end 94 | 95 | it 'calls #action for each passed param' do 96 | expect(row).to have_received(:action).with(:show) 97 | expect(row).to have_received(:action).with(:destroy) 98 | end 99 | end 100 | end 101 | 102 | describe '#action' do 103 | subject { row.to_html } 104 | 105 | context 'URL' do 106 | context 'with an explicit URL' do 107 | before { row.action(:show, '/') } 108 | 109 | it 'generates a link with the specified URL' do 110 | is_expected.to have_tag(:a, with: { href: '/' }) 111 | end 112 | end 113 | 114 | context 'with no explicit URL' do 115 | let(:row) { row_subclass.new(resource, view) } 116 | let(:row_subclass) do 117 | Class.new(ResourceRow) do 118 | def show_action_url 119 | "/posts/#{resource.to_param}" 120 | end 121 | end 122 | end 123 | 124 | before { row.action(:show) } 125 | 126 | it 'uses a method to build the URL (ie. show_url)' do 127 | is_expected.to have_tag(:a, with: { href: '/posts/1' }) 128 | end 129 | end 130 | 131 | context 'with no explicit URL and no action name' do 132 | it 'raises an ArgumentError' do 133 | expect { row.action(:show) }.to raise_error(ArgumentError) 134 | end 135 | end 136 | end 137 | 138 | context 'with no arguments' do 139 | it 'raises an ArgumentError' do 140 | expect { row.action }.to raise_error(ArgumentError) 141 | end 142 | end 143 | 144 | context 'td cell' do 145 | before { row.action(:show, '/') } 146 | 147 | it 'generates a td cell with actions role' do 148 | is_expected.to have_tag(:td, with: { role: 'actions' }) 149 | end 150 | end 151 | 152 | context 'link role' do 153 | before { row.action(:show, '/') } 154 | 155 | it 'generates a link with role' do 156 | is_expected.to have_tag(:a, with: { role: 'show' }) 157 | end 158 | end 159 | 160 | context 'link text' do 161 | context do 162 | before { row.action(:show, '/') } 163 | 164 | it 'generates a link with a titleized attribute' do 165 | is_expected.to have_tag(:a, text: 'Show') 166 | end 167 | end 168 | 169 | context 'if I18n is set up' do 170 | before do 171 | I18n.backend.store_translations( 172 | :en, 173 | table: { actions: { post: { show: 'Show post' } } } 174 | ) 175 | end 176 | 177 | before { row.action(:show, '/') } 178 | 179 | it 'generates a I18n text' do 180 | is_expected.to have_tag(:a, text: 'Show post') 181 | end 182 | end 183 | end 184 | 185 | context 'with html options' do 186 | before { row.action(:show, '/', class: 'foo') } 187 | 188 | it 'renders them as attributes' do 189 | is_expected.to have_tag(:a, with: { class: 'foo' }) 190 | end 191 | 192 | context 'with a class that implements a _html_options' do 193 | let(:row) { row_subclass.new(resource, view) } 194 | let(:row_subclass) do 195 | Class.new(ResourceRow) do 196 | def action_html_options(action_name) 197 | { class: 'button' } 198 | end 199 | 200 | def show_action_html_options 201 | { class: 'show-button' } 202 | end 203 | end 204 | end 205 | 206 | it 'renders them as attributes' do 207 | is_expected.to have_tag(:a, with: { class: 'foo show-button button' }) 208 | end 209 | end 210 | end 211 | 212 | context 'with block' do 213 | before do 214 | row.action { 'Foo' } 215 | end 216 | 217 | it 'renders it' do 218 | is_expected.to have_tag(:td, text: 'Foo') 219 | end 220 | end 221 | end 222 | end 223 | end 224 | end 225 | 226 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Admino Logo](https://raw.github.com/cantierecreativo/admino/master/logo.jpg) 2 | 3 | [![Gem Version](https://badge.fury.io/rb/admino.png)](http://badge.fury.io/rb/admino) 4 | [![Build Status](https://travis-ci.org/cantierecreativo/admino.png?branch=v0.0.1)](https://travis-ci.org/cantierecreativo/admino) 5 | [![Coverage Status](https://coveralls.io/repos/cantierecreativo/admino/badge.png?branch=master)](https://coveralls.io/r/cantierecreativo/admino?branch=master) 6 | [![Code Climate](https://codeclimate.com/github/cantierecreativo/admino.png)](https://codeclimate.com/github/cantierecreativo/admino) 7 | 8 | A minimal, object-oriented solution to generate Rails administrative index views. Through query objects and presenters, it features a customizable table generator and search forms with filtering/sorting. 9 | 10 | ## The philosophy behind it 11 | 12 | The Rails ecosystem has many [full-fledged solutions to generate administrative interfaces](https://www.ruby-toolbox.com/categories/rails_admin_interfaces). 13 | 14 | Although these tools are very handy to bootstrap a project quickly, they all obey the [80%-20% rule](http://en.wikipedia.org/wiki/Pareto_principle) and tend to be very invasive, often mixing up different concerns on a single responsibility level, thus making tests unbelievably difficult to setup and write. 15 | 16 | A time comes when these all-encompassing tools get in the way. And that will be the moment where all the cumulated saved time will be wasted to solve a single, trivial problem with ugly workarounds and [epic facepalms](http://i.imgur.com/ghKDGyv.jpg). 17 | 18 | So yes, if you're starting a small, short-lived project, go ahead with them, it will be fine! If you're building something that's more valuable or is meant to last longer, there are better alternatives. 19 | 20 | ### A modular approach to the problem 21 | 22 | The great thing is that you don't need to write a lot of code to get a more maintainable and modular administrative area. 23 | Gems like [Inherited Resources](https://github.com/josevalim/inherited_resources) and [Simple Form](https://github.com/plataformatec/simple_form), combined with [Rails 3.1+ template-inheritance](http://railscasts.com/episodes/269-template-inheritance) already give you ~90% of the time-saving features and the same super-DRY, declarative code that administrative interfaces offer, but with a far more relaxed contract. 24 | 25 | If a particular controller or view needs something different from the standard CRUD/REST treatment, you can just avoid using those gems in that specific context, and fall back to standard Rails code. No workarounds, no facepalms. It seems easy, right? It is. 26 | 27 | So what about Admino? Well, it complements the above-mentioned gems, giving you the the missing ~10%: a fast way to generate administrative index views. 28 | 29 | ## Demo 30 | 31 | To better illustrate how to create a 100%-custom, super-DRY administrative interface using Admino and the aforementioned gems, we prepared a [repo with a sample Rails project](https://github.com/cantierecreativo/admino-example) you can take a look. The app is browsable at [http://admino-example.herokuapp.com](http://admino-example.herokuapp.com), and features a Bootstrap 3 theme. 32 | 33 | ## Installation 34 | 35 | Add this line to your application's Gemfile: 36 | 37 | gem 'admino' 38 | 39 | And then execute: 40 | 41 | $ bundle 42 | 43 | ## Admino::Query::Base 44 | 45 | `Admino::Query::Base` implements the [Query object](http://martinfowler.com/eaaCatalog/queryObject.html) pattern, that is, an object responsible for returning a result set (ie. an `ActiveRecord::Relation`) based on business rules. 46 | 47 | Given a `Task` model, we can generate a `TasksQuery` query object subclassing `Admino::Query::Base`: 48 | 49 | ```ruby 50 | class TasksQuery < Admino::Query::Base 51 | end 52 | ``` 53 | 54 | Each query object gets initialized with a hash of params, and features a `#scope` method that returns the filtered/sorted result set. As you may have guessed, query objects can be great companions to index actions: 55 | 56 | ```ruby 57 | class TasksController < ApplicationController 58 | def index 59 | @query = TasksQuery.new(params) 60 | @tasks = @query.scope 61 | end 62 | end 63 | ``` 64 | 65 | ### Building the query itself 66 | 67 | You can specify how a `TaskQuery` must build a result set through a simple DSL. 68 | 69 | #### `starting_scope` 70 | 71 | The `starting_scope` method is in charge of defining the scope that will start the filtering/ordering chain: 72 | 73 | ```ruby 74 | class TasksQuery < Admino::Query::Base 75 | starting_scope { Task.all } 76 | end 77 | 78 | Task.create(title: 'Low priority task') 79 | 80 | TaskQuery.new.scope.count # => 1 81 | ``` 82 | 83 | #### `search_field` 84 | 85 | Once you define the following field: 86 | 87 | ```ruby 88 | class TasksQuery < Admino::Query::Base 89 | # ... 90 | search_field :title_matches 91 | end 92 | ``` 93 | 94 | The `#scope` method will check the presence of the `params[:query][:title_matches]` key. If it finds it, it will augment the query with a named scope called `:title_matches`, expected to be found within the `Task` model. The scope needs to accept an argument. 95 | 96 | ```ruby 97 | class Task < ActiveRecord::Base 98 | scope :title_matches, ->(text) { 99 | where('title ILIKE ?', "%#{text}%") 100 | } 101 | end 102 | 103 | Task.create(title: 'Low priority task') 104 | Task.create(title: 'Fix me ASAP!!1!') 105 | 106 | TaskQuery.new.scope.count # => 2 107 | TaskQuery.new(query: { title_matches: 'ASAP' }).scope.count # => 1 108 | ``` 109 | 110 | You can provide a default value with the `default` option: 111 | 112 | ```ruby 113 | class TasksQuery < Admino::Query::Base 114 | # ... 115 | search_field :title_matches, default: 'TODO' 116 | end 117 | ``` 118 | 119 | #### `filter_by` 120 | 121 | ```ruby 122 | class Task < ActiveRecord::Base 123 | enum :status, [:pending, :completed, :archived] 124 | scope :title_matches, ->(text) { 125 | where('title ILIKE ?', "%#{text}%") 126 | } 127 | end 128 | 129 | class TasksQuery < Admino::Query::Base 130 | # ... 131 | filter_by :status, [:completed, :pending] 132 | filter_by :deleted, [:with_deleted] 133 | filter_by :status, Task.statuses.keys 134 | end 135 | ``` 136 | 137 | Just like a search field, with a declared filter group the `#scope` method will check the presence of a `params[:query][:status]` key. If it finds it (and its value corresponds to one of the declared scopes) it will augment the query with the scope itself: 138 | 139 | ```ruby 140 | class Task < ActiveRecord::Base 141 | scope :completed, -> { where(completed: true) } 142 | scope :pending, -> { where(completed: false) } 143 | end 144 | 145 | Task.create(title: 'First task', completed: true) 146 | Task.create(title: 'Second task', completed: true) 147 | Task.create(title: 'Third task', completed: false) 148 | 149 | TaskQuery.new.scope.count # => 3 150 | TaskQuery.new(query: { status: 'completed' }).scope.count # => 2 151 | TaskQuery.new(query: { status: 'pending' }).scope.count # => 1 152 | TaskQuery.new(query: { status: 'foobar' }).scope.count # => 3 153 | ``` 154 | 155 | You can include a "reset" scope with the `include_empty_scope` option, and provide a default scope with the `default` option: 156 | 157 | ```ruby 158 | class TasksQuery < Admino::Query::Base 159 | # ... 160 | filter_by :time, [:last_month, :last_week], 161 | include_empty_scope: true, 162 | default: :last_week 163 | end 164 | ``` 165 | 166 | #### `sorting` 167 | 168 | ```ruby 169 | class TasksQuery < Admino::Query::Base 170 | # ... 171 | sorting :by_due_date, :by_title 172 | end 173 | ``` 174 | 175 | Once you declare some sorting scopes, the query object looks for a `params[:sorting]` key. If it exists (and corresponds to one of the declared scopes), it will augment the query with the scope itself. The model named scope will be called passing an argument that represents the direction of sorting (`:asc` or `:desc`). 176 | 177 | The direction passed to the scope will depend on the value of `params[:sort_order]`, and will default to `:asc`: 178 | 179 | ```ruby 180 | class Task < ActiveRecord::Base 181 | scope :by_due_date, ->(direction) { order(due_date: direction) } 182 | scope :by_title, ->(direction) { order(title: direction) } 183 | end 184 | 185 | expired_task = Task.create(due_date: 1.year.ago) 186 | future_task = Task.create(due_date: 1.week.since) 187 | 188 | TaskQuery.new(sorting: 'by_due_date', sort_order: 'desc').scope # => [ future_task, expired_task ] 189 | TaskQuery.new(sorting: 'by_due_date', sort_order: 'asc').scope # => [ expired_task, future_task ] 190 | TaskQuery.new(sorting: 'by_due_date').scope # => [ expired_task, future_task ] 191 | ``` 192 | 193 | #### `ending_scope` 194 | 195 | It's very common ie. to paginate a result set. The block declared in the `ending_scope` block will be always appended to the end of the chain: 196 | 197 | ```ruby 198 | class TasksQuery < Admino::Query::Base 199 | ending_scope { |q| page(q.params[:page]) } 200 | end 201 | ``` 202 | 203 | ### Let the query object do the chaining 204 | 205 | If you do not want to pollute your ActiveRecord model with all these scopes, you are free to implement them on the query object itself (just make sure to suffix them with `_scope`): 206 | 207 | ```ruby 208 | class Task < ActiveRecord::Base 209 | end 210 | 211 | class TasksQuery < Admino::Query::Base 212 | search_field :title_matches 213 | 214 | def title_matches_scope(scope, text) 215 | scope.where('title ILIKE ?', "%#{text}%") 216 | end 217 | end 218 | ``` 219 | 220 | ### Inspecting the query state 221 | 222 | A query object supports various methods to inspect the available search fields, filters and sortings, and their state: 223 | 224 | ```ruby 225 | query = TaskQuery.new 226 | query.search_fields # => [ #, ... ] 227 | query.filter_groups # => [ #, ... ] 228 | 229 | search_field = query.search_field_by_name(:title_matches) 230 | 231 | search_field.name # => :title_matches 232 | search_field.present? # => true 233 | search_field.value # => 'ASAP' 234 | 235 | filter_group = query.filter_group_by_name(:status) 236 | 237 | filter_group.name # => :status 238 | filter_group.scopes # => [ :completed, :pending ] 239 | filter_group.active_scope # => :completed 240 | filter_group.is_scope_active?(:pending) # => false 241 | 242 | sorting = query.sorting # => # 243 | sorting.scopes # => [ :by_title, :by_due_date ] 244 | sorting.active_scope # => :by_due_date 245 | sorting.is_scope_active?(:by_title) # => false 246 | sorting.ascending? # => true 247 | ``` 248 | 249 | ### Presenting search form and filters to the user 250 | 251 | Admino offers some helpers that make it really easy to generate search forms and filtering links: 252 | 253 | ```erb 254 | <%# generate the search form %> 255 | <%= search_form_for(query) do |q| %> 256 | <%# generate inputs from search_fields %> 257 |

258 | <%= q.label :title_matches %> 259 | <%= q.text_field :title_matches %> 260 |

261 |

262 | <%= q.submit %> 263 |

264 | 265 | <%# generate inputs from filter_by %> 266 |

267 | <%= q.label :status %> 268 | <%= q.select :status, Task.statuses.keys %> 269 |

270 | 271 | <%# if filter_by has only one scope you can use a checkbox %> 272 |

273 | <%= q.check_box :deleted, {}, checked_value: "with_deleted" %> 274 | <%= q.label :deleted %> 275 |

276 | <% end %> 277 | 278 | <%# generate the filtering links %> 279 | <% filters_for(query) do |filter_group| %> 280 |
<%= filter_group.name %>
281 |
    282 | <% filter_group.each_scope do |scope| %> 283 |
  • <%= scope.link %>
  • 284 | <% end %> 285 |
286 | <% end %> 287 | 288 | <%# generate the sorting links %> 289 |
Sort by
290 |
    291 | <% sortings_for(query) do |scope| %> 292 |
  • <%= scope.link %>
  • 293 | <% end %> 294 |
295 | ``` 296 | The great thing is that: 297 | 298 | * the search form gets automatically filled in with the last input the user submitted 299 | * a `is-active` CSS class gets added to the currently active filter scopes 300 | * if a particular filter link has been clicked and is now active, it is possible to deactivate it by clicking on the link again 301 | * a `is-asc`/`is-desc` CSS class gets added to the currently active sorting scope 302 | * if a particular sorting scope link has been clicked and is now in ascending order, it is possible to make it descending by clicking on the link again 303 | 304 | ### Simple Form support 305 | 306 | If you prefer using [Simple Form](https://github.com/plataformatec/simple_form), please use the `simple_search_form_for` helper instead. 307 | 308 | ### Output customization 309 | 310 | The `#link` methods are very flexible, allowing you to change almost every aspect of the generated links: 311 | 312 | ```erb 313 | <% filter_group.each_scope do |scope| %> 314 |
  • <%= scope.link 'Custom title', 315 | active_class: 'active', 316 | class: 'custom-class' 317 | %>
  • 318 | <% end %> 319 | ``` 320 | 321 | Please refer to the tests for the details. 322 | 323 | ### Overwriting the starting scope 324 | 325 | Suppose you have to filter the tasks based on the `@current_user` work group. You can easily provide an alternative starting scope from the controller passing it as an argument to the `#scope` method: 326 | 327 | ```ruby 328 | def index 329 | @query = TasksQuery.new(params) 330 | @project_tasks = @query.scope(@current_user.team.tasks) 331 | end 332 | ``` 333 | 334 | ### Coertions 335 | 336 | Suppose the presence of a model scope that requires a non-textual argument (ie. a date): 337 | 338 | ```ruby 339 | class Task < ActiveRecord::Base 340 | scope :due_date_from, ->(date) { where('due_date >= ?', date) } 341 | end 342 | ``` 343 | 344 | Admino can perform some automatic coertions to the textual parameter it gets, and pass the coerced value to the scope: 345 | 346 | ```ruby 347 | class TasksQuery < Admino::Query::Base 348 | search_field :due_date_from, coerce: :to_date 349 | end 350 | 351 | query = TaskQuery.new(query: { due_date_from: '2014-03-01' }) 352 | query.search_field_by_name(:due_date_from).value # => # 353 | ``` 354 | 355 | If a specific coercion cannot be performed with the provided input, the scope won't be chained. The following coertions are available: 356 | 357 | * `:to_boolean` 358 | * `:to_constant` 359 | * `:to_date` 360 | * `:to_datetime` 361 | * `:to_decimal` 362 | * `:to_float` 363 | * `:to_integer` 364 | * `:to_symbol` 365 | * `:to_time` 366 | 367 | Please see the [`Coercible::Coercer::String`](https://github.com/solnic/coercible/blob/master/lib/coercible/coercer/string.rb) class for details. 368 | 369 | ### Default sorting 370 | 371 | If you need to setup a default sorting, you can pass some optional arguments to the `sorting` declaration: 372 | 373 | ```ruby 374 | class TasksQuery < Admino::Query::Base 375 | # ... 376 | sorting :by_due_date, :by_title, 377 | default_scope: :by_due_date, 378 | default_direction: :desc 379 | end 380 | ``` 381 | 382 | ### I18n 383 | 384 | To localize the search form labels, as well as the group filter names and scope links, please refer to the following YAML file: 385 | 386 | ```yaml 387 | en: 388 | query: 389 | attributes: 390 | tasks_query: 391 | title_matches: 'Title contains' 392 | filter_groups: 393 | tasks_query: 394 | status: 395 | name: 'Filter by status' 396 | scopes: 397 | completed: 'Completed' 398 | pending: 'Pending' 399 | sorting_scopes: 400 | task_query: 401 | by_due_date: 'By due date' 402 | by_title: 'By title' 403 | ``` 404 | 405 | ## Admino::Table::Presenter 406 | 407 | Admino offers a `table_for` helper that makes it really easy to generate HTML tables from a set of records: 408 | 409 | ```erb 410 | <%= table_for(@tasks, class: Task) do |row, record| %> 411 | <%= row.column :title %> 412 | <%= row.column :completed do %> 413 | <%= record.completed ? '✓' : '✗' %> 414 | <% end %> 415 | <%= row.column :due_date %> 416 | <% end %> 417 | ``` 418 | 419 | With produces the following output: 420 | 421 | ```html 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 |
    TitleCompletedDue date
    Call mum ASAP2013-02-04
    441 | ``` 442 | 443 | ### Record actions 444 | 445 | Often tables need to offer some kind of action associated with the records. The table builder implements the following DSL to support that: 446 | 447 | ```erb 448 | <%= table_for(@tasks, class: Task) do |row, record| %> 449 | <%# ... %> 450 | <%= row.actions do %> 451 | <%= row.action :show, admin_task_path(record) %> 452 | <%= row.action :edit, edit_admin_task_path(record) %> 453 | <%= row.action :destroy, admin_task_path(record), method: :delete %> 454 | <% end %> 455 | <% end %> 456 | ``` 457 | 458 | ```html 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 474 | 475 | 476 |
    Actions
    470 | Show 471 | Edit 472 | Destroy 473 |
    477 | ``` 478 | 479 | ### Sortable columns 480 | 481 | If you want to make the table headers sortable, then please create an Admino query object class to define the available sorting scopes. 482 | 483 | ```ruby 484 | class TaskQuery < Admino::Query::Base 485 | sorting :by_title, :by_due_date 486 | end 487 | ``` 488 | 489 | You can then pass the query object as a parameter to the table presenter initializer, and associate table columns to specific sorting scopes of the query object using the `sorting` directive: 490 | 491 | ```erb 492 | <% query = present(@query) %> 493 | 494 | <%= table_for(@tasks, class: Task) do |row, record| %> 495 | <%= row.column :title, sorting: :by_title %> 496 | <%= row.column :due_date, sorting: :by_due_date %> 497 | <% end %> 498 | ``` 499 | 500 | This generates links that allow the visitor to sort the result set in ascending and descending direction: 501 | 502 | ```html 503 | 504 | 505 | 506 | 509 | 512 | 513 | 514 | 515 |
    507 | Title 508 | 510 | Due date 511 |
    516 | ``` 517 | 518 | ### Customizing the output 519 | 520 | The `#column` and `#action` methods are very flexible, allowing you to change almost every aspect of the generated table cells: 521 | 522 | ```erb 523 | <%= table_for(@tasks, class: Task, html: { class: 'table-class' }) do |row, record| %> 524 | <%= row.column :title, 'Custom title', 525 | class: 'custom-class', role: 'custom-role', data: { custom: 'true' }, 526 | sorting: :by_title, sorting_html_options: { desc_class: 'down' } 527 | %> 528 | <%= row.action :show, admin_task_path(record), 'Custom label', 529 | class: 'custom-class', role: 'custom-role', data: { custom: 'true' } 530 | %> 531 | <% end %> 532 | ``` 533 | 534 | If you need more power, you can also subclass `Admino::Table::Presenter`. For each HTML element, there's a set of methods you can override to customize it's appeareance. 535 | Table cells are generated through two collaborator classes: `Admino::Table::HeadRow` and `Admino::Table::ResourceRow`. You can easily replace them with a subclass if you want. To grasp the idea here's an example: 536 | 537 | ```ruby 538 | class CustomTablePresenter < Admino::Table::Presenter 539 | private 540 | 541 | def table_html_options 542 | { class: 'table-class' } 543 | end 544 | 545 | def tbody_tr_html_options(resource, index) 546 | { class: 'tr-class' } 547 | end 548 | 549 | def zebra_css_classes 550 | %w(one two three) 551 | end 552 | 553 | def resource_row(resource, view_context) 554 | ResourceRow.new(resource, view_context) 555 | end 556 | 557 | def head_row(collection_klass, query, view_context) 558 | HeadRow.new(collection_klass, query, view_context) 559 | end 560 | 561 | class ResourceRow < Admino::Table::ResourceRow 562 | private 563 | 564 | def action_html_options(action_name) 565 | { class: 'action-class' } 566 | end 567 | 568 | def show_action_html_options 569 | { class: 'show-action-class' } 570 | end 571 | 572 | def column_html_options(attribute_name) 573 | { class: 'column-class' } 574 | end 575 | end 576 | 577 | class HeadRow < Admino::Table::ResourceRow 578 | def column_html_options(attribute_name) 579 | { class: 'column-class' } 580 | end 581 | end 582 | end 583 | ``` 584 | 585 | ```erb 586 | <%= table_for(@tasks, class: Task, presenter: CustomTablePresenter) do |row, record| %> 587 | <%= row.column :title, 'Custom title', 588 | class: 'custom-class', role: 'custom-role', data: { custom: 'true' }, 589 | sorting: :by_title, sorting_html_options: { desc_class: 'down' } 590 | %> 591 | <%= row.action :show, admin_task_path(record), 'Custom label', 592 | class: 'custom-class', role: 'custom-role', data: { custom: 'true' } 593 | %> 594 | <% end %> 595 | ``` 596 | 597 | Please refer to the tests for all the details. 598 | 599 | ### Inherited resources (and similar) 600 | 601 | If your controller actions are generated through [Inherited Resources](https://github.com/josevalim/inherited_resources), then you can always get the URL pointing to the show action with the `resource_path` helper method. Similar helpers [are available for the other REST actions too](https://github.com/josevalim/inherited_resources#url-helpers) (new, edit, destroy). 602 | 603 | More in general, if you are able to programmatically generate/obtain the URLs of your row actions, you can subclass `Admino::Table::Presenter` and declare them: 604 | 605 | ```ruby 606 | class CustomTablePresenter < Admino::Table::Presenter 607 | private 608 | 609 | def resource_row(resource, view_context) 610 | ResourceRow.new(resource, view_context) 611 | end 612 | 613 | class ResourceRow < Admino::Table::ResourceRow 614 | def show_action_url 615 | h.resource_url(resource) 616 | end 617 | 618 | def edit_action_url 619 | h.edit_resource_url(resource) 620 | end 621 | 622 | def destroy_action_url 623 | h.resource_url(resource) 624 | end 625 | 626 | def destroy_action_html_options 627 | { method: :delete } 628 | end 629 | end 630 | end 631 | ``` 632 | 633 | This will enable you to generate row actions even faster, simply declaring them as arguments to the `#actions` DSL method: 634 | 635 | ```erb 636 | <%= table_for(@tasks, class: Task, presenter: CustomTablePresenter) do |row, record| %> 637 | <%# ... %> 638 | <%= row.actions :show, :edit, :destroy %> 639 | <% end %> 640 | ``` 641 | 642 | ### Showcase::Traits::Record 643 | 644 | As funny it may sound, it is strongly suggested to pass to the table presenter an array of records which in turn have been already presented. This enables you to use as columns not only the raw attributes of the model, but all the methods defined in the presenter. 645 | 646 | Furthermore, if the record presenter includes the `Showcase::Traits::Record` trait, each row of the table will automatically have an unique id attribute thanks to the [`#dom_id` method](https://github.com/stefanoverna/showcase#dom_id). 647 | 648 | ```ruby 649 | class TaskPresenter < Showcase::Presenter 650 | include Showcase::Traits::Record 651 | 652 | def truncated_title 653 | h.truncate(title, length: 50) 654 | end 655 | end 656 | ``` 657 | 658 | ```erb 659 | <% tasks = present_collection(@tasks) 660 | 661 | <%= Admino::Table::Presenter.new(tasks, Task, self).to_html do |row, record| %> 662 | <%= row.column :truncated_title, 'Title' %> 663 | <% end %> 664 | ``` 665 | 666 | ```html 667 | 668 | 669 | 670 | 671 | 672 | 673 | 674 | 675 | 676 | 677 | 678 | 679 |
    Title
    Call mum ASAP
    Buy some milk
    680 | ``` 681 | 682 | ### I18n 683 | 684 | Column titles are generated using the model [`#human_attribute_name`](http://apidock.com/rails/ActiveRecord/Base/human_attribute_name/class) method, so if you already translated the model attribute names, you're good to go. To translate actions, please refer to the following YAML file: 685 | 686 | ```yaml 687 | en: 688 | activerecord: 689 | attributes: 690 | task: 691 | title: 'Title' 692 | due_date: 'Due date' 693 | completed: 'Completed?' 694 | table: 695 | actions: 696 | task: 697 | title: 'Actions' 698 | show: 'Details' 699 | edit: 'Edit task' 700 | destroy: 'Delete' 701 | ``` 702 | 703 | ## Running tests 704 | 705 | Install gems: 706 | 707 | ``` 708 | $ bundle 709 | $ bundle exec appraisal 710 | ``` 711 | 712 | Launch tests: 713 | 714 | ``` 715 | bundle exec appraisal rake 716 | ``` 717 | --------------------------------------------------------------------------------