├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console ├── git-hooks │ └── pre-commit ├── install_git_hooks └── setup ├── gemfiles ├── rails_4.gemfile ├── rails_5_0.gemfile └── rails_5_1.gemfile ├── lib ├── assets │ ├── javascripts │ │ └── rails_stuff │ │ │ └── plugin_manager.coffee │ └── stylesheets │ │ └── rails_stuff │ │ └── _media_queries.scss ├── net │ └── http │ │ └── debug.rb ├── rails_stuff.rb └── rails_stuff │ ├── association_writer.rb │ ├── engine.rb │ ├── generators │ └── concern │ │ ├── USAGE │ │ ├── concern_generator.rb │ │ └── templates │ │ └── concern.rb │ ├── helpers.rb │ ├── helpers │ ├── all.rb │ ├── bootstrap.rb │ ├── forms.rb │ ├── links.rb │ ├── resource_form.rb │ ├── text.rb │ └── translation.rb │ ├── nullify_blank_attrs.rb │ ├── params_parser.rb │ ├── random_uniq_attr.rb │ ├── redis_storage.rb │ ├── require_nested.rb │ ├── resources_controller.rb │ ├── resources_controller │ ├── actions.rb │ ├── basic_helpers.rb │ ├── belongs_to.rb │ ├── has_scope_helpers.rb │ ├── kaminari_helpers.rb │ ├── resource_helper.rb │ └── sti_helpers.rb │ ├── responders.rb │ ├── responders │ └── turbolinks.rb │ ├── rspec_helpers.rb │ ├── rspec_helpers │ ├── concurrency.rb │ ├── groups │ │ ├── feature.rb │ │ └── request.rb │ ├── matchers │ │ ├── be_valid_js.rb │ │ └── redirect_with_turbolinks.rb │ └── signinable.rb │ ├── sort_scope.rb │ ├── statusable.rb │ ├── statusable │ ├── builder.rb │ ├── helper.rb │ ├── mapped_builder.rb │ └── mapped_helper.rb │ ├── strong_parameters.rb │ ├── test_helpers.rb │ ├── test_helpers │ ├── concurrency.rb │ ├── integration_session.rb │ └── response.rb │ ├── transform_attrs.rb │ ├── types_tracker.rb │ ├── url_for_keeping_params.rb │ └── version.rb ├── rails_stuff.gemspec └── spec ├── integration └── requests │ └── site │ ├── forms_spec.rb │ ├── projects_spec.rb │ └── users_spec.rb ├── integration_helper.rb ├── rails_helper.rb ├── rails_stuff ├── association_writer_spec.rb ├── generators │ └── concern_generator_spec.rb ├── helpers │ ├── bootstrap_spec.rb │ ├── forms_spec.rb │ ├── links_spec.rb │ ├── resource_form_spec.rb │ ├── text_spec.rb │ └── translation_spec.rb ├── nullify_blank_attrs_spec.rb ├── params_parser_spec.rb ├── random_uniq_attr_spec.rb ├── redis_storage_spec.rb ├── resources_controller │ ├── actions_spec.rb │ ├── basic_helpers_spec.rb │ ├── resource_helper_spec.rb │ └── sti_helpers_spec.rb ├── resources_controller_spec.rb ├── sort_scope_spec.rb ├── statusable │ ├── builder_spec.rb │ ├── helper_spec.rb │ ├── mapped_builder_spec.rb │ └── mapped_helper_spec.rb ├── statusable_spec.rb ├── strong_parameters_spec.rb ├── test_helpers │ └── concurrency_spec.rb ├── transform_attrs_spec.rb ├── types_tracker_spec.rb └── url_for_keeping_params_spec.rb ├── rails_stuff_spec.rb ├── spec_helper.rb └── support ├── active_record.rb ├── app └── views │ └── site │ ├── forms │ └── index.html.erb │ ├── projects │ ├── edit.html.erb │ └── index.html.erb │ └── users │ ├── edit.html.erb │ ├── index.html.erb │ ├── new.html.erb │ └── show.html.erb ├── controller.rb ├── redis.rb ├── schema.rb └── shared └── statusable.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /gemfiles/*.lock 5 | /_yardoc/ 6 | /coverage/ 7 | /doc/ 8 | /pkg/ 9 | /spec/reports/ 10 | /tmp/ 11 | /log/ 12 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Exclude: 3 | - lib/rails_stuff/generators/*/templates/*.rb 4 | - tmp/**/* 5 | Rails: {Enabled: true} 6 | 7 | Layout/AlignParameters: 8 | # Disable, till rubocop supports combination of styles. 9 | # Use one of this styles where appropriate, keep it clean, compact and readable. 10 | Enabled: false 11 | # EnforcedStyle: 12 | # - with_first_parameter 13 | # - with_fixed_indentation 14 | 15 | # Breaks 16 | # 17 | # I18n.t(key, 18 | # param: val, 19 | # # ... 20 | # ) 21 | Layout/ClosingParenthesisIndentation: {Enabled: false} 22 | Layout/DotPosition: {EnforcedStyle: trailing} 23 | # Same as Layout/ClosingParenthesisIndentation 24 | Layout/MultilineMethodCallBraceLayout: {Enabled: false} 25 | Layout/MultilineMethodCallIndentation: {EnforcedStyle: indented} 26 | Layout/MultilineOperationIndentation: {EnforcedStyle: indented} 27 | Layout/SpaceInsideHashLiteralBraces: {EnforcedStyle: no_space} 28 | 29 | # Offences named scopes and `expect {}.to change {}`. 30 | Lint/AmbiguousBlockAssociation: {Enabled: false} 31 | 32 | # We use it in describe messages. 33 | Lint/InterpolationCheck: 34 | Exclude: 35 | - spec/**/* 36 | 37 | Naming/PredicateName: {Enabled: false} 38 | Naming/VariableNumber: {EnforcedStyle: snake_case} 39 | 40 | Style/Alias: {Enabled: false} 41 | Style/Documentation: {Enabled: false} 42 | Style/IfUnlessModifier: {Enabled: false} 43 | Style/GuardClause: {Enabled: false} 44 | Style/Lambda: {Enabled: false} 45 | Style/ModuleFunction: {Enabled: false} 46 | Style/NestedParenthesizedCalls: {Enabled: false} 47 | Style/SignalException: {EnforcedStyle: only_raise} 48 | Style/TrailingCommaInArguments: {Enabled: false} 49 | Style/TrailingCommaInLiteral: {EnforcedStyleForMultiline: comma} 50 | 51 | Metrics/AbcSize: {Max: 21} 52 | # Other metrics are just enough. 53 | # This one offences all specs, routes and some initializers. 54 | Metrics/BlockLength: {Enabled: false} 55 | Metrics/LineLength: {Max: 100} 56 | Metrics/MethodLength: {Max: 30} 57 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | cache: bundler 3 | rvm: 4 | - 2.2.3 5 | gemfile: 6 | - gemfiles/rails_4.gemfile 7 | - gemfiles/rails_5_0.gemfile 8 | - gemfiles/rails_5_1.gemfile 9 | services: 10 | - redis 11 | notifications: 12 | email: false 13 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise 'rails-4' do 2 | group :development do 3 | gem 'rails', '~> 4.0' 4 | end 5 | end 6 | 7 | appraise 'rails-5_0' do 8 | group :development do 9 | gem 'rails', '~> 5.0.0' 10 | gem 'rails-controller-testing', '~> 0.1.1' 11 | end 12 | end 13 | 14 | appraise 'rails-5_1' do 15 | group :development do 16 | gem 'rails', '~> 5.1' 17 | gem 'rails-controller-testing', '~> 0.1.1' 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Unreleased 2 | 3 | ### Controllers 4 | 5 | - `resource_params` returns permited hash when resource key is not present in `params`. 6 | - `use_resource_class_for_invalid_type` option for STI helpers, 7 | and `RailsStuff::ResourcesController::StiHelpers::InvalidType` error instead of 8 | `ActiveRecord::NotFound` for invalid type. 9 | 10 | ### Models 11 | 12 | - `TransformAttrs` in favour of `NullifyBlankAttrs` (deprecated). 13 | 14 | ### Misc 15 | 16 | - Parse BigDecimals with ParamsParser. 17 | - ParamsParser parses empty strings to `nil` (except `parse_string`). 18 | 19 | 20 | ## 0.6.0.rc1 21 | 22 | ### Controllers 23 | 24 | - Don't overwrite :location option if present. 25 | - Don't overwrite responder and `respond_with`'s mimes. 26 | - `#current_sort_scope` to access applied sorting rules. 27 | - `.resource_helper` has `source` option, which accepts lambdas and strings. 28 | `class` option is deprecated now in favour of `source`. 29 | 30 | ### Models 31 | 32 | - Improved Statusable: 33 | 34 | - Model are now clear from lot of helper methods. 35 | There is single `statuses` method for statusable field which holds all helpers. 36 | - `.select_options` supports `:only` option. 37 | - Helpers to map/unmap values for mapped statuses from external code. 38 | 39 | ### Tests 40 | 41 | - A lot of new test and rspec helpers and configs (see RSpecHelpers, TestHelpers). 42 | - `RSpecHelpers.setup` & `TestHelpers.setup` to setup helpers instead of requiring 43 | files. This methods accepts `only` & `except` options. 44 | 45 | ### Misc 46 | 47 | - Use AR#update instead of #update_attributes. 48 | 49 | ## 0.5.1 50 | 51 | Rails 5 support. 52 | 53 | ## 0.5.0 54 | 55 | ### Controllers 56 | 57 | - `belongs_to`. 58 | - `resource_helper` generates enquirer method. 59 | - `resources_controller kaminari: false` to skip kaminari for the only controller. 60 | - `has_sort_scope` can use custom order method. 61 | - Fix: removed source_for_collection from action_methods. 62 | 63 | ### Models 64 | 65 | - Statusable supports mappings (store status as integer) and suffixes. 66 | - AssociationWriter to override `#field=` & `#field_id=` in single instruction. 67 | - Limit retries count for RandomUniqAttr (default to 10). 68 | 69 | ### Helpers 70 | 71 | - `Helpers::Translation` methods can raise errors on missing translations. 72 | It respects app's `raise_on_missing_translations`, and can be configured manually. 73 | 74 | ### Tests 75 | 76 | - Concurrency helper. 77 | - RSpec configurator. 78 | 79 | Misc 80 | 81 | - RequireNested to require all files in subdirectory. 82 | - `rails g concern %parent%/%module%` generator for concerns. 83 | 84 | ## 0.4.0 85 | 86 | - TypesTracker defines scopes for every type. 87 | 88 | ## 0.3.0 89 | 90 | - PluginManager & media queries. 91 | 92 | ## 0.2.0 93 | 94 | - Bypass block to `respond_with`. 95 | 96 | - `url_for_keeping_params` 97 | 98 | - `params.require_permitted` 99 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | 4 | group :development do 5 | gem 'appraisal', '~> 2.1.0' 6 | gem 'rails' 7 | 8 | gem 'pry', '~> 0.10.1' 9 | gem 'pry-byebug', '~> 3.2.0' 10 | gem 'sdoc', '~> 0.4.1' 11 | 12 | gem 'activemodel_translation', '~> 0.1.0' 13 | gem 'database_cleaner', '~> 1.5.0' 14 | gem 'has_scope', '~> 0.7.0' 15 | gem 'hashie', '~> 3.4.0' 16 | gem 'kaminari', '~> 0.16.3' 17 | gem 'pooled_redis', '~> 0.2.1' 18 | gem 'responders', '~> 2.1' 19 | gem 'sqlite3', '~> 1.3.10' 20 | 21 | gem 'rspec-its', '~> 1.2.0' 22 | gem 'rspec-rails', '~> 3.5' 23 | 24 | gem 'rubocop', '~> 0.51.0' 25 | 26 | gem 'coveralls', '~> 0.8.2', require: false 27 | end 28 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Max Melentiev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task default: :spec 7 | 8 | require 'sdoc' 9 | RDoc::Task.new(:doc) do |rdoc| 10 | rdoc.rdoc_dir = 'doc' 11 | 12 | rdoc.title = 'RailsStuff' 13 | 14 | rdoc.options << '--markup' << 'markdown' 15 | rdoc.options << '-e' << 'UTF-8' 16 | rdoc.options << '--format' << 'sdoc' 17 | rdoc.options << '--template' << 'rails' 18 | rdoc.options << '--all' 19 | 20 | rdoc.rdoc_files.include('README.md') 21 | rdoc.rdoc_files.include('lib/**/*.rb') 22 | end 23 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'rails_stuff' 5 | 6 | require 'pry' 7 | Pry.start 8 | -------------------------------------------------------------------------------- /bin/git-hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | pattern=$(echo -n '\.rb 4 | \.gemspec 5 | \.jbuilder 6 | \.rake 7 | config\.ru 8 | Gemfile 9 | Rakefile' | tr "\\n" '|') 10 | 11 | files=`git diff --cached --name-status | grep -E "^[AM].*($pattern)$" | cut -f2-` 12 | if [ -n "$files" ]; then 13 | bundle exec rubocop $files --force-exclusion 14 | fi 15 | -------------------------------------------------------------------------------- /bin/install_git_hooks: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | root = File.expand_path('../../', __FILE__) 4 | hooks_dir = "#{root}/bin/git-hooks" 5 | 6 | `ls -1 #{hooks_dir}`.each_line.map(&:strip).each do |file| 7 | `ln -sf #{hooks_dir}/#{file} #{root}/.git/hooks/#{file}` 8 | end 9 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | appraisal install 7 | bin/install_git_hooks 8 | 9 | # Do any other automated setup that you need to do here 10 | -------------------------------------------------------------------------------- /gemfiles/rails_4.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | group :development do 6 | gem "appraisal", "~> 2.1.0" 7 | gem "rails", "~> 4.0" 8 | gem "pry", "~> 0.10.1" 9 | gem "pry-byebug", "~> 3.2.0" 10 | gem "sdoc", "~> 0.4.1" 11 | gem "activemodel_translation", "~> 0.1.0" 12 | gem "database_cleaner", "~> 1.5.0" 13 | gem "has_scope", "~> 0.7.0" 14 | gem "hashie", "~> 3.4.0" 15 | gem "kaminari", "~> 0.16.3" 16 | gem "pooled_redis", "~> 0.2.1" 17 | gem "responders", "~> 2.1" 18 | gem "sqlite3", "~> 1.3.10" 19 | gem "rspec-its", "~> 1.2.0" 20 | gem "rspec-rails", "~> 3.5" 21 | gem "rubocop", "~> 0.51.0" 22 | gem "coveralls", "~> 0.8.2", :require => false 23 | end 24 | 25 | gemspec :path => "../" 26 | -------------------------------------------------------------------------------- /gemfiles/rails_5_0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | group :development do 6 | gem "appraisal", "~> 2.1.0" 7 | gem "rails", "~> 5.0.0" 8 | gem "pry", "~> 0.10.1" 9 | gem "pry-byebug", "~> 3.2.0" 10 | gem "sdoc", "~> 0.4.1" 11 | gem "activemodel_translation", "~> 0.1.0" 12 | gem "database_cleaner", "~> 1.5.0" 13 | gem "has_scope", "~> 0.7.0" 14 | gem "hashie", "~> 3.4.0" 15 | gem "kaminari", "~> 0.16.3" 16 | gem "pooled_redis", "~> 0.2.1" 17 | gem "responders", "~> 2.1" 18 | gem "sqlite3", "~> 1.3.10" 19 | gem "rspec-its", "~> 1.2.0" 20 | gem "rspec-rails", "~> 3.5" 21 | gem "rubocop", "~> 0.51.0" 22 | gem "coveralls", "~> 0.8.2", :require => false 23 | gem "rails-controller-testing", "~> 0.1.1" 24 | end 25 | 26 | gemspec :path => "../" 27 | -------------------------------------------------------------------------------- /gemfiles/rails_5_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | group :development do 6 | gem "appraisal", "~> 2.1.0" 7 | gem "rails", "~> 5.1" 8 | gem "pry", "~> 0.10.1" 9 | gem "pry-byebug", "~> 3.2.0" 10 | gem "sdoc", "~> 0.4.1" 11 | gem "activemodel_translation", "~> 0.1.0" 12 | gem "database_cleaner", "~> 1.5.0" 13 | gem "has_scope", "~> 0.7.0" 14 | gem "hashie", "~> 3.4.0" 15 | gem "kaminari", "~> 0.16.3" 16 | gem "pooled_redis", "~> 0.2.1" 17 | gem "responders", "~> 2.1" 18 | gem "sqlite3", "~> 1.3.10" 19 | gem "rspec-its", "~> 1.2.0" 20 | gem "rspec-rails", "~> 3.5" 21 | gem "rubocop", "~> 0.51.0" 22 | gem "coveralls", "~> 0.8.2", :require => false 23 | gem "rails-controller-testing", "~> 0.1.1" 24 | end 25 | 26 | gemspec :path => "../" 27 | -------------------------------------------------------------------------------- /lib/assets/javascripts/rails_stuff/plugin_manager.coffee: -------------------------------------------------------------------------------- 1 | # Provides simple way to create jQuery plugins. Create class and PluginManager 2 | # will create jQuery function for it. It'll create instance of class for each 3 | # jQuery element and prevent calling constructor twice. 4 | # 5 | # PluginManager.add 'myPlugin', class 6 | # constructor: (@$element, @options) -> 7 | # # ... 8 | # 9 | # customAction: (options)-> 10 | # # ... 11 | # 12 | # # Add initializers 13 | # $ -> $('[data-my-plugin]').myPlugin() 14 | # # or 15 | # $(document).on 'click', '[data-my-plugin]', (e) -> 16 | # $(@).myPlugin('customAction', event: e) 17 | # 18 | # # Or use it manually 19 | # $('.selector').myPlugin().myPlugin('customAction') 20 | class window.PluginManager 21 | @plugins = {} 22 | 23 | # Takes class and creates jQuery's plugin function for it. 24 | # This function simply creates class instance for each element 25 | # and prevents creating multiple instances on single element. 26 | # 27 | # Name is set explicitly to avoid errors when using uglifier. 28 | @add: (pluginName, klass) -> 29 | data_index = "#{pluginName}.instance" 30 | @plugins[pluginName] = klass 31 | jQuery.fn[pluginName] = (action, options) -> 32 | if typeof action is 'object' 33 | options = action 34 | action = null 35 | @each -> 36 | $this = jQuery @ 37 | unless instance = $this.data data_index 38 | instance = new klass $this, options 39 | $this.data data_index, instance 40 | instance[action](options) if action 41 | -------------------------------------------------------------------------------- /lib/assets/stylesheets/rails_stuff/_media_queries.scss: -------------------------------------------------------------------------------- 1 | // Foundation-style media queries. 2 | // Works with Bootstrap3 out of the box. 3 | // Without bootstrap just define variables: 4 | // screen-xs-max, screen-{sm,md}-{min,max}, screen-lg-min 5 | // 6 | // Usage: 7 | // 8 | // @media #{$sm-up} { ... } 9 | // @media #{$sm-only} and #{$landscape} { ... } 10 | $screen: "only screen" !default; 11 | 12 | $landscape: "(orientation: landscape)" !default; 13 | $portrait: "(orientation: portrait)" !default; 14 | 15 | $xs-up: $screen !default; 16 | $xs-only: "(max-width: #{$screen-xs-max})" !default; 17 | 18 | $sm-up: "(min-width:#{$screen-sm-min})" !default; 19 | $sm-only: "(min-width:#{$screen-sm-min}) and (max-width:#{$screen-sm-max})" !default; 20 | 21 | $md-up: "(min-width:#{$screen-md-min})" !default; 22 | $md-only: "(min-width:#{$screen-md-min}) and (max-width:#{$screen-md-max})" !default; 23 | 24 | $lg-up: "(min-width:#{$screen-lg-min})" !default; 25 | $lg-only: "(min-width:#{$screen-lg-min})" !default; 26 | -------------------------------------------------------------------------------- /lib/net/http/debug.rb: -------------------------------------------------------------------------------- 1 | require 'net/http' 2 | 3 | class << Net::HTTP 4 | # Redefines `.new` to set debug device for all new instances. 5 | def debug!(out = $stderr) 6 | return if respond_to?(:__new__) 7 | class << self 8 | alias_method :__new__, :new 9 | end 10 | 11 | define_singleton_method :new do |*args, &blk| 12 | instance = __new__(*args, &blk) 13 | instance.set_debug_output(out) 14 | instance 15 | end 16 | end 17 | 18 | # Restores original `.new`. 19 | def disable_debug! 20 | return unless respond_to?(:__new__) 21 | class << self 22 | alias_method :new, :__new__ 23 | remove_method :__new__ 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/rails_stuff.rb: -------------------------------------------------------------------------------- 1 | require 'rails_stuff/version' 2 | require 'active_support/version' 3 | require 'active_support/dependencies/autoload' 4 | 5 | # Useful stuff for Rails. 6 | module RailsStuff 7 | extend ActiveSupport::Autoload 8 | 9 | autoload :Helpers 10 | autoload :AssociationWriter 11 | autoload :NullifyBlankAttrs 12 | autoload :ParamsParser 13 | autoload :RandomUniqAttr 14 | autoload :RedisStorage 15 | autoload :RequireNested 16 | autoload :ResourcesController 17 | autoload :Responders 18 | autoload :RSpecHelpers, 'rails_stuff/rspec_helpers' 19 | autoload :SortScope 20 | autoload :Statusable 21 | autoload :TestHelpers, 'rails_stuff/test_helpers' 22 | autoload :TransformAttrs 23 | autoload :TypesTracker 24 | 25 | module_function 26 | 27 | def rails_version 28 | @rails_version = ActiveSupport::VERSION 29 | end 30 | 31 | def rails4? 32 | rails_version::MAJOR == 4 33 | end 34 | 35 | def deprecation_07 36 | @deprecation ||= begin 37 | require 'active_support/deprecation' 38 | ActiveSupport::Deprecation.new('0.7', 'RailsStuff') 39 | end 40 | end 41 | end 42 | 43 | require 'rails_stuff/engine' if defined?(Rails::Engine) 44 | -------------------------------------------------------------------------------- /lib/rails_stuff/association_writer.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | # ActiveRecord's association can be updated with object and by object id. 3 | # Owerwrite this both writers with single instruction: 4 | # 5 | # association_writer :product do |val| 6 | # super(val).tap { update_price if product } 7 | # end 8 | # 9 | module AssociationWriter 10 | def association_writer(name, &block) 11 | define_method("#{name}=", &block) 12 | define_method("#{name}_id=", &block) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/rails_stuff/engine.rb: -------------------------------------------------------------------------------- 1 | require 'rails/railtie' 2 | 3 | module RailsStuff 4 | MODULES = { # rubocop:disable MutableConstant 5 | require_nested: [:require, -> { RequireNested.setup }], 6 | association_writer: :model, 7 | nullify_blank_attrs: :model, 8 | random_uniq_attr: :model, 9 | statusable: :model, 10 | transform_attrs: :model, 11 | resources_controller: [ 12 | -> { defined?(::Responders) && :controller }, 13 | -> { ResourcesController.use_kaminari! if defined?(::Kaminari) }, 14 | ], 15 | sort_scope: -> { defined?(::HasScope) && :controller }, 16 | strong_parameters: -> { defined?(ActionController::Parameters) && :require }, 17 | url_for_keeping_params: -> { defined?(ActionDispatch::Routing) && :require }, 18 | } 19 | 20 | class << self 21 | # Set it to array of modules to load. 22 | # 23 | # # config/initializers/rails_stuff.rb 24 | # RailsStuff.load_modules = [:statusable, :sort_scope] 25 | attr_accessor :load_modules 26 | # Override default base classes for models & controllers. 27 | attr_writer :base_controller, :base_model 28 | 29 | def base_controller 30 | @base_controller || ActionController::Base 31 | end 32 | 33 | def base_model 34 | @base_model || ActiveRecord::Base 35 | end 36 | 37 | # Extends base controller and model classes with modules. 38 | # By default uses all modules. Use load_modules= to override this list. 39 | def setup_modules! 40 | modules_to_load = load_modules || MODULES.keys 41 | MODULES.slice(*modules_to_load).each do |m, (type, init)| 42 | case type.respond_to?(:call) ? type.call : type 43 | when :controller 44 | RailsStuff.base_controller.extend const_get(m.to_s.camelize) 45 | when :model 46 | RailsStuff.base_model.extend const_get(m.to_s.camelize) 47 | when :require 48 | require "rails_stuff/#{m}" 49 | end 50 | init.try!(:call) 51 | end 52 | end 53 | end 54 | 55 | class Engine < Rails::Engine 56 | initializer :rails_stuff_setup_modules, after: :load_config_initializers do 57 | RailsStuff.setup_modules! 58 | end 59 | 60 | generators do 61 | require 'rails_stuff/generators/concern/concern_generator' 62 | end 63 | 64 | config.action_dispatch.rescue_responses.merge!( 65 | 'RailsStuff::ResourcesController::StiHelpers::InvalidType' => :unprocessable_entity, 66 | ) 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/rails_stuff/generators/concern/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | Generates concern for the class. 3 | 4 | Example: 5 | `rails generate concern User::Authentication` 6 | 7 | create app/models/user/authentication.rb 8 | 9 | `rails generate concern admin_controller/localized` 10 | 11 | create app/controllers/admin_controller/localized.rb 12 | -------------------------------------------------------------------------------- /lib/rails_stuff/generators/concern/concern_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | 3 | module RailsStuff 4 | module Generators 5 | class ConcernGenerator < Rails::Generators::NamedBase # :nodoc: 6 | argument :base, 7 | type: :string, 8 | required: false, 9 | banner: 'Base dir with the class', 10 | description: 'Use it when class is not inside app/models or app/controllers.' 11 | 12 | namespace 'rails:concern' 13 | source_root File.expand_path('../templates', __FILE__) 14 | 15 | def create_concern_files 16 | template 'concern.rb', File.join(base_path, "#{file_path}.rb") 17 | end 18 | 19 | private 20 | 21 | def base_path 22 | base || file_path.include?('_controller/') ? 'app/controllers' : 'app/models' 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/rails_stuff/generators/concern/templates/concern.rb: -------------------------------------------------------------------------------- 1 | module <%= class_name %> 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | end 6 | 7 | module ClassMethods 8 | def class_method 9 | end 10 | end 11 | 12 | def instance_method 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/rails_stuff/helpers.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module Helpers 3 | extend ActiveSupport::Autoload 4 | 5 | autoload :Bootstrap 6 | autoload :Forms 7 | autoload :Links 8 | autoload :ResourceForm 9 | autoload :Text 10 | autoload :Translation 11 | 12 | autoload :All 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/rails_stuff/helpers/all.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module Helpers 3 | # Collection of all helper modules. 4 | module All 5 | include Translation 6 | include Links 7 | include Bootstrap 8 | include Text 9 | include Forms 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/rails_stuff/helpers/bootstrap.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/hash/keys' 2 | require 'active_support/core_ext/hash/transform_values' 3 | 4 | module RailsStuff 5 | module Helpers 6 | module Bootstrap 7 | BOOTSTRAP_FLASH_TYPE = { 8 | success: 'alert-success', 9 | error: 'alert-danger', 10 | alert: 'alert-warning', 11 | notice: 'alert-info', 12 | }.stringify_keys.freeze 13 | 14 | CROSS = '×'.html_safe.freeze # rubocop:disable Rails/OutputSafety 15 | 16 | def flash_messages 17 | messages = flash.map do |type, message| 18 | content_tag :div, class: [:alert, BOOTSTRAP_FLASH_TYPE[type] || type] do 19 | content_tag(:button, CROSS, class: :close, data: {dismiss: :alert}) + 20 | simple_format(message) 21 | end 22 | end 23 | safe_join(messages) 24 | end 25 | 26 | ICONS = { 27 | destroy: %(), 28 | edit: %(), 29 | new: %(), 30 | }.tap { |x| x.transform_values!(&:html_safe) if ''.respond_to?(:html_safe) } 31 | 32 | def basic_link_icons 33 | ICONS 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/rails_stuff/helpers/forms.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module Helpers 3 | module Forms 4 | # Returns hidden field tags for requested fields when they are present in params. 5 | # Usually used to bypass params in GET-forms. 6 | def hidden_params_fields(*fields) 7 | inputs = fields.flat_map do |field| 8 | next unless params.key?(field) 9 | val = params[field] 10 | if val.is_a?(Array) 11 | name = "#{field}[]" 12 | val.map { |str| [name, str] } 13 | else 14 | [[field, val]] 15 | end 16 | end 17 | safe_join inputs.map { |(name, val)| hidden_field_tag name, val if name } 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/rails_stuff/helpers/links.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module Helpers 3 | # Link helpers for basic actions. 4 | module Links 5 | ICONS = { # rubocop:disable MutableConstant 6 | destroy: -> { translate_action(:destroy) }, 7 | edit: -> { translate_action(:edit) }, 8 | new: -> { translate_action(:new) }, 9 | } 10 | 11 | def basic_link_icons 12 | ICONS 13 | end 14 | 15 | def basic_link_icon(action) 16 | val = basic_link_icons[action] 17 | val.is_a?(Proc) ? instance_exec(&val) : val 18 | end 19 | 20 | def link_to_destroy(url, **options) 21 | link_to basic_link_icon(:destroy), url, { 22 | title: translate_action(:delete), 23 | method: :delete, 24 | data: {confirm: translate_confirmation(:delete)}, 25 | }.merge!(options) 26 | end 27 | 28 | def link_to_edit(url = nil, **options) 29 | link_to basic_link_icon(:edit), (url || url_for(action: :edit)), 30 | {title: translate_action(:edit)}.merge!(options) 31 | end 32 | 33 | def link_to_new(url = nil, **options) 34 | link_to basic_link_icon(:new), (url || url_for(action: :new)), options 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/rails_stuff/helpers/resource_form.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/array/wrap' 2 | module RailsStuff 3 | module Helpers 4 | # Provides helper for SimpleForm. 5 | module ResourceForm 6 | # Generates `resource_form` helper to display form with basic arguments, 7 | # elements, errors and options. Generated method can work without arguments 8 | # in most of cases: it takes object from `resource` method. 9 | # 10 | # Use `namespace` to add additional path parts to form action: 11 | # 12 | # # this one will use [:site, resource] 13 | # resource_form_for :site 14 | # 15 | # #### Options 16 | # 17 | # - `back_url` - default back url. Can be string with code, or hash for `url_for`. 18 | # - `resource_method` - method to take resource from. 19 | # - `method` - name of generated method. 20 | # 21 | def resource_form_for(namespace = nil, **options) 22 | default_back_url = 23 | case options[:back_url] 24 | when Hash then "url_for(#{options[:back_url]})" 25 | when String then options[:back_url] 26 | else 'url_for(object)' 27 | end 28 | resource_method = options.fetch(:resource_method, :resource) 29 | method_name = options.fetch(:method, :resource_form) 30 | object_arg = (Array.wrap(namespace).map(&:inspect) + [resource_method]).join(', ') 31 | 32 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 33 | def #{method_name}(object = [#{object_arg}], **options) 34 | back_url = options.delete(:back_url) || #{default_back_url} 35 | simple_form_for object, options do |f| 36 | html = ActiveSupport::SafeBuffer.new 37 | msg = f.object.errors[:base].first 38 | html << content_tag(:div, msg, class: 'alert alert-danger') if msg 39 | html << capture { yield(f) } 40 | html << content_tag(:div, class: 'form-group') do 41 | inputs = f.button(:submit, class: 'btn-primary') 42 | inputs << ' ' 43 | inputs << link_to(translate_action(:cancel), back_url, class: :btn) 44 | end 45 | end 46 | end 47 | RUBY 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/rails_stuff/helpers/text.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module Helpers 3 | module Text 4 | # Replaces blank values with cached placeholder from translations. 5 | # When called with block, it'll check value for blankness, but returns 6 | # block's result if value is present. 7 | # 8 | # replace_blank(description) 9 | # replace_blank(tags) { tags.join(', ') } 10 | # replace_blank(order.paid_at) { |x| l x, format: :long } 11 | # 12 | def replace_blank(value, &block) 13 | if value.blank? 14 | blank_placeholder 15 | else 16 | block_given? ? capture(value, &block) : value 17 | end 18 | end 19 | 20 | # Default placeholder value. 21 | def blank_placeholder 22 | @_blank_placeholder ||= content_tag :small, 23 | "(#{I18n.t(:'helpers.placeholder.blank', default: '-')})", 24 | class: :'text-muted' 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/rails_stuff/helpers/translation.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module Helpers 3 | module Translation 4 | class << self 5 | def i18n_raise 6 | @i18n_raise ||= defined?(Rails) && ActionView::Base.raise_on_missing_translations 7 | end 8 | 9 | attr_writer :i18n_raise 10 | end 11 | 12 | # Translates & caches actions within `helpers.actions` scope. 13 | def translate_action(action) 14 | @translate_action ||= Hash.new do |h, key| 15 | h[key] = I18n.t("helpers.actions.#{key}", raise: Translation.i18n_raise) 16 | end 17 | @translate_action[action] 18 | end 19 | 20 | # Translates & caches confirmations within `helpers.confirmations` scope. 21 | def translate_confirmation(action) 22 | @translate_confirmation ||= Hash.new do |h, key| 23 | h[key] = I18n.t("helpers.confirmations.#{key}", 24 | default: [:'helpers.confirm'], 25 | raise: Translation.i18n_raise, 26 | ) 27 | end 28 | @translate_confirmation[action] 29 | end 30 | 31 | # Translates boolean values. 32 | def yes_no(val) 33 | @translate_yes_no ||= Hash.new do |h, key| 34 | h[key] = I18n.t("helpers.yes_no.#{key}", raise: Translation.i18n_raise) 35 | end 36 | @translate_yes_no[val.to_s] 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/rails_stuff/nullify_blank_attrs.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/object/blank' 2 | 3 | module RailsStuff 4 | # Changes to `nil` assigned blank attributes. 5 | # 6 | # class App 7 | # nullify_blank_attrs :site_url 8 | # # ... 9 | module NullifyBlankAttrs 10 | def nullify_blank_attrs(*attrs) 11 | RailsStuff.deprecation_07.warn('Use transform_attrs *attrs, with: :nullify') 12 | nullify_blank_attrs_methods.class_eval do 13 | attrs.each do |attr| 14 | define_method("#{attr}=") { |val| super(val.presence) } 15 | end 16 | end 17 | end 18 | 19 | # Module to store generated methods, so they can be overriden in model. 20 | def nullify_blank_attrs_methods 21 | @nullify_blank_attrs_methods ||= Module.new.tap { |x| prepend x } 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/rails_stuff/params_parser.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | # Provides parsing and type-casting functions. 3 | # Reraises all ocured errors with Error class, so you can handle it together: 4 | # 5 | # rescue_from RailsStuff::ParamsParser::Error, with: :render_bad_request 6 | # 7 | # You can define more parsing methods by extending with this module 8 | # and using .parse: 9 | # 10 | # # models/params_parser 11 | # module ParamsParser 12 | # extend RailsStuff::ParamsParser 13 | # extend self 14 | # 15 | # def parse_money(val) 16 | # parse(val) { your_stuff(val) } 17 | # end 18 | # end 19 | # 20 | module ParamsParser 21 | # This exceptions is wrapper for any exception occured in parser. 22 | # Original exception message can be retrieved with `original_message` method. 23 | class Error < ::StandardError 24 | attr_reader :original_message, :value 25 | 26 | def initialize(original_message = nil, value = nil) 27 | message = "Error while parsing: #{value.inspect}" 28 | @original_message = original_message || message 29 | @value = value 30 | super(message) 31 | end 32 | 33 | # Keeps message when passing instance to `raise`. 34 | def exception(*) 35 | self 36 | end 37 | 38 | # Show original messages in tests. 39 | def to_s 40 | "#{super} (#{original_message})" 41 | end 42 | end 43 | 44 | extend self 45 | 46 | # Parses value with specified block. Reraises occured error with Error. 47 | def parse(val, *args, &block) 48 | parse_not_blank(val, *args, &block) 49 | rescue => e # rubocop:disable Lint/RescueWithoutErrorClass 50 | raise Error.new(e.message, val), nil, e.backtrace 51 | end 52 | 53 | # Parses each value in array with specified block. 54 | # Returns `nil` if `val` is not an array. 55 | def parse_array(array, *args, &block) 56 | return unless array.is_a?(Array) 57 | parse(array) { array.map { |val| parse_not_blank(val, *args, &block) } } 58 | end 59 | 60 | # Parses value with given block only when it is not nil. 61 | # Empty string is converted to nil. Pass `allow_blank: true` to return it as is. 62 | def parse_not_blank(val, allow_blank: false) 63 | return if val.nil? || !allow_blank && val.is_a?(String) && val.blank? 64 | yield(val) 65 | end 66 | 67 | # :method: parse_int 68 | # :call-seq: parse_int(val) 69 | # 70 | # Parse int value. 71 | 72 | # :method: parse_int_array 73 | # :call-seq: parse_int_array(val) 74 | # 75 | # Parses array of ints. Returns `nil` if `val` is not an array. 76 | 77 | # :method: parse_float 78 | # :call-seq: parse_float(val) 79 | # 80 | # Parse float value. 81 | 82 | # :method: parse_float_array 83 | # :call-seq: parse_float_array(val) 84 | # 85 | # Parses array of floats. Returns `nil` if `val` is not an array. 86 | 87 | # Parsers for generic types, which are implemented with #to_i, #to_f & #to_s 88 | # methods. 89 | %w[int float].each do |type| 90 | block = :"to_#{type[0]}".to_proc 91 | 92 | define_method "parse_#{type}" do |val| 93 | parse(val, &block) 94 | end 95 | 96 | define_method "parse_#{type}_array" do |val| 97 | parse_array(val, &block) 98 | end 99 | end 100 | 101 | # Parse string value. 102 | def parse_string(val) 103 | parse(val, allow_blank: true, &:to_s) 104 | end 105 | 106 | # Parses array of strings. Returns `nil` if `val` is not an array. 107 | def parse_string_array(val) 108 | parse_array(val, allow_blank: true, &:to_s) 109 | end 110 | 111 | # Parse decimal value. 112 | def parse_decimal(val) 113 | parse(val) { |x| string_to_decimal(x) } 114 | end 115 | 116 | # Parses array of decimals. Returns `nil` if `val` is not an array. 117 | def parse_decimal_array(val) 118 | parse_array(val) { |x| string_to_decimal(x) } 119 | end 120 | 121 | # Parse boolean using ActiveResord's parser. 122 | def parse_boolean(val) 123 | parse(val) do 124 | @boolean_parser ||= boolean_parser 125 | @boolean_parser[val] 126 | end 127 | end 128 | 129 | def boolean_parser 130 | require 'active_record' 131 | ar_parser = ActiveRecord::Type::Boolean.new 132 | if RailsStuff.rails4? 133 | ->(val) { ar_parser.type_cast_from_user(val) } 134 | else 135 | ->(val) { ar_parser.cast(val) } 136 | end 137 | end 138 | 139 | # Parse time in current TZ using `Time.parse`. 140 | def parse_datetime(val) 141 | parse(val) { Time.zone.parse(val) || raise('Invalid datetime') } 142 | end 143 | 144 | # Parse JSON string. 145 | def parse_json(val) 146 | parse(val) { JSON.parse(val) } 147 | end 148 | 149 | private 150 | 151 | # Workaround for https://github.com/ruby/bigdecimal/issues/63 152 | def string_to_decimal(val) 153 | BigDecimal.new(val) 154 | rescue ArgumentError 155 | BigDecimal.new(0) 156 | end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /lib/rails_stuff/random_uniq_attr.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | # Provides save way to generate uniq random values for ActiveRecord models. 3 | # You need to make field nullable and add unique index on it. 4 | # The way it works: 5 | # 6 | # - Instance is saved as usual 7 | # - If random fields are not empty, it does nothing 8 | # - Generates random value and tries to update instance 9 | # - If `RecordNotUnique` is occured, it keeps trying to generate new values. 10 | # 11 | module RandomUniqAttr 12 | DEFAULT_GENERATOR = ->(*) { SecureRandom.hex(32) } 13 | MAX_ATTEMPTS = 10 14 | 15 | class << self 16 | # Made from `Devise.firendly_token` with increased length. 17 | def friendly_token(length = 32) 18 | SecureRandom.urlsafe_base64(length).tr('lIO0', 'sxyz') 19 | end 20 | end 21 | 22 | # Generates necessary methods and setups on-create callback for the `field`. 23 | # You can optionally pass custom generator function: 24 | # 25 | # random_uniq_attr(:code) { |instance| my_random(instance) } 26 | # 27 | def random_uniq_attr(field, **options, &block) 28 | set_method = :"set_#{field}" 29 | generate_method = :"generate_#{field}" 30 | max_attempts = options.fetch(:max_attempts) { MAX_ATTEMPTS } 31 | 32 | after_create set_method, unless: :"#{field}?" 33 | 34 | # def self.generate_key 35 | define_singleton_method generate_method, &(block || DEFAULT_GENERATOR) 36 | 37 | # def set_key 38 | define_method(set_method) do 39 | attempt = 0 40 | begin 41 | raise 'Available only for persisted record' unless persisted? 42 | transaction(requires_new: true) do 43 | new_value = self.class.send(generate_method, self) 44 | update_column field, new_value # rubocop:disable Rails/SkipsModelValidations 45 | end 46 | rescue ActiveRecord::RecordNotUnique 47 | attempt += 1 48 | raise if attempt > max_attempts 49 | retry 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/rails_stuff/redis_storage.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/module/delegation' 2 | require 'active_support/core_ext/module/remove_method' 3 | require 'active_support/core_ext/object/blank' 4 | 5 | module RailsStuff 6 | # Provides methods to store data in redis. Can be easily integrated into 7 | # ActiveRecor or other class. 8 | # 9 | # Redis is accessed via with_redis method which uses redis_pool 10 | # (default to `Rails.redis_pool`, see `pooled_redis` gem) to checkout connection. 11 | # Basic methods are #get, #delete and #set. 12 | # 13 | # Redis keys are generated from requested key and redis_prefix 14 | # (default to underscored class name). You can pass an array as a key and all the 15 | # parts will be concatenated with `:`. #set automalically generates 16 | # sequential keys, if given key is `nil` (last element of array is `nil`). 17 | # 18 | # It uses `dump` and `load` methods to encode values 19 | # (by default delegated to `Marshal`). 20 | module RedisStorage 21 | # Serializers 22 | delegate :dump, :load, to: Marshal 23 | 24 | # Redis connections pool. Default to `Rails.redis_pool`. 25 | # Override this method to change it. 26 | def redis_pool 27 | Rails.redis_pool 28 | end 29 | 30 | # Options to use in SET command. Use to set EX, or smth. 31 | def redis_set_options 32 | {} 33 | end 34 | 35 | # :method: redis_pool= 36 | # :call-seq: redis_pool= 37 | # 38 | # Set redis_pool. 39 | 40 | # :method: redis_set_options= 41 | # :call-seq: redis_set_options= 42 | # 43 | # Set redis_set_options. 44 | 45 | # Setters that overrides methods, so new values are inherited without recursive `super`. 46 | %i[redis_pool redis_set_options].each do |name| 47 | define_method "#{name}=" do |val| 48 | singleton_class.class_eval do 49 | remove_possible_method(name) 50 | define_method(name) { val } 51 | end 52 | end 53 | end 54 | 55 | # Checkout connection & run block with it. 56 | def with_redis(&block) 57 | redis_pool.with(&block) 58 | end 59 | 60 | # Prefix that used in every key for a model. Default to pluralized model name. 61 | def redis_prefix 62 | @redis_prefix ||= name.underscore 63 | end 64 | 65 | # Override default redis_prefix. 66 | attr_writer :redis_prefix 67 | 68 | # Generates key for given `id`(s) prefixed with #redis_prefix. 69 | # Multiple ids are joined with `:`. 70 | def redis_key_for(id) 71 | "#{redis_prefix}:#{Array(id).join(':')}" 72 | end 73 | 74 | # Generates key to store current maximum id. Examples: 75 | # 76 | # users_id_seq 77 | # user_id_seq:eu 78 | def redis_id_seq_key(id = []) 79 | postfix = Array(id).join(':') 80 | "#{redis_prefix}_id_seq#{":#{postfix}" if postfix.present?}" 81 | end 82 | 83 | # Generate next ID. It stores counter separately and uses 84 | # it to retrieve next id. 85 | def next_id(*args) 86 | with_redis { |redis| redis.incr(redis_id_seq_key(*args)) } 87 | end 88 | 89 | # Reset ID counter. 90 | def reset_id_seq(*args) 91 | with_redis { |redis| redis.del(redis_id_seq_key(*args)) } 92 | end 93 | 94 | # Saves value to redis. If `id` is `nil`, it's generated with #next_id. 95 | # Returns last part of id / generated id. 96 | def set(id, value, options = {}) 97 | id = Array(id) 98 | id.push(nil) if id.empty? 99 | id[id.size - 1] ||= next_id(id[0..-2]) 100 | with_redis do |redis| 101 | redis.set(redis_key_for(id), dump(value), redis_set_options.merge(options)) 102 | end 103 | id.last 104 | end 105 | 106 | # Reads value from redis. 107 | def get(id) 108 | return unless id 109 | with_redis { |redis| redis.get(redis_key_for(id)).try { |data| load(data) } } 110 | end 111 | 112 | # Remove record from redis. 113 | def delete(id) 114 | return true unless id 115 | with_redis { |redis| redis.del(redis_key_for(id)) } 116 | true 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/rails_stuff/require_nested.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/dependencies' 2 | 3 | module RailsStuff 4 | module RequireNested 5 | class << self 6 | # Make #require_nested available in module. 7 | def setup 8 | Module.include(self) 9 | end 10 | end 11 | 12 | module_function 13 | 14 | # Requires nested modules with `require_dependency`. 15 | # Pass custom directory to require its content. 16 | # By default uses caller's filename with stripped `.rb` extension from. 17 | def require_nested(dir = 0) 18 | dir = caller_locations(dir + 1, 1)[0].path.sub(/\.rb$/, '') if dir.is_a?(Integer) 19 | Dir["#{dir}/*.rb"].each { |file| require_dependency file } 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/rails_stuff/resources_controller.rb: -------------------------------------------------------------------------------- 1 | require 'responders' 2 | 3 | module RailsStuff 4 | # InheritedResources on diet. 5 | # Tiny and simple implementation. Feel free to change/extend it right in you 6 | # application. Or just use separate modules. 7 | module ResourcesController 8 | extend ActiveSupport::Autoload 9 | 10 | autoload :Actions 11 | autoload :BasicHelpers 12 | autoload :BelongsTo 13 | autoload :HasScopeHelpers 14 | autoload :KaminariHelpers 15 | autoload :ResourceHelper 16 | autoload :Responder 17 | autoload :StiHelpers 18 | 19 | extend KaminariHelpers::ConfigMethods 20 | 21 | class << self 22 | def inject(base, **options) 23 | base.include BasicHelpers 24 | base.include KaminariHelpers if options.fetch(:kaminari) { use_kaminari? } 25 | base.include StiHelpers if options[:sti] 26 | base.include Actions 27 | base.extend HasScopeHelpers 28 | base.extend ResourceHelper 29 | base.extend BelongsTo 30 | end 31 | end 32 | 33 | # Setups basic actions and helpers in resources controller. 34 | # 35 | # #### Options 36 | # 37 | # - `sti` - include STI helpers 38 | # - `kaminari` - include Kaminari helpers 39 | # - `after_save_action` - action to use for `after_save_url` 40 | # - `source_relation` - override `source_relation` 41 | def resources_controller(**options) 42 | ResourcesController.inject(self, **options) 43 | 44 | self.after_save_action = options[:after_save_action] || after_save_action 45 | 46 | resource_belongs_to(*options[:belongs_to]) if options[:belongs_to] 47 | if options[:source_relation] 48 | protected define_method(:source_relation, &options[:source_relation]) 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/rails_stuff/resources_controller/actions.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module ResourcesController 3 | # Basic actions for resources controller. 4 | module Actions 5 | def new 6 | build_resource 7 | end 8 | 9 | def create(options = {}, &block) 10 | if create_resource 11 | options[:location] ||= after_save_url 12 | end 13 | respond_with(resource, options, &block) 14 | end 15 | 16 | def update(options = {}, &block) 17 | if update_resource 18 | options[:location] ||= after_save_url 19 | end 20 | respond_with(resource, options, &block) 21 | end 22 | 23 | def destroy(options = {}, &block) 24 | resource.destroy 25 | options[:location] ||= after_destroy_url 26 | flash_errors! 27 | respond_with(resource, options, &block) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/rails_stuff/resources_controller/basic_helpers.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module ResourcesController 3 | module BasicHelpers 4 | extend ActiveSupport::Concern 5 | 6 | included do 7 | helper_method :resource, :collection 8 | self.after_save_action = :show 9 | end 10 | 11 | module ClassMethods 12 | attr_writer :resource_class, 13 | :resource_param_name, 14 | :permitted_attrs 15 | 16 | # Defines action to redirect after resource was saved. Default to `:show`. 17 | attr_accessor :after_save_action 18 | 19 | # Resource class for controller. Default to class, based on 20 | # demodulized controller name. 21 | def resource_class 22 | @resource_class ||= 23 | Object.const_get(name.to_s.demodulize.sub(/Controller$/, '').singularize) 24 | end 25 | 26 | # Key to lookup for resource attributes in `params`. 27 | # Default to class'es `param_key`. 28 | def resource_param_name 29 | @resource_param_name ||= resource_class.model_name.param_key 30 | end 31 | 32 | # Class-level permitted attributes. 33 | # 34 | # `attr_reader`, default to `[]`. 35 | def permitted_attrs 36 | @permitted_attrs ||= [] 37 | end 38 | 39 | # Concats `@permitted_attrs` variable with given attrs. 40 | def permit_attrs(*attrs) 41 | permitted_attrs.concat attrs 42 | end 43 | 44 | # Prevent CanCan's implementation. 45 | def authorize_resource 46 | raise 'use `before_action :authorize_resource!` instead' 47 | end 48 | end 49 | 50 | protected 51 | 52 | # Accesss resources collection. 53 | def collection 54 | @_collection ||= source_for_collection 55 | end 56 | 57 | # End-point relation to be used as source for `collection`. 58 | def source_for_collection 59 | source_relation 60 | end 61 | 62 | # Relation which is used to find and build resources. 63 | def source_relation 64 | self.class.resource_class 65 | end 66 | 67 | # Resource found by `params[:id]` 68 | def resource 69 | @_resource ||= source_relation.find params[:id] 70 | end 71 | 72 | # Instantiate resource with attrs from `resource_params`. 73 | def build_resource(attrs = resource_params) 74 | @_resource = source_relation.new(attrs) 75 | end 76 | 77 | # Builds and saves resource. 78 | def create_resource 79 | build_resource 80 | resource.save 81 | end 82 | 83 | # Updates resource with `resource_params`. 84 | def update_resource(attrs = resource_params) 85 | resource.update(attrs) 86 | end 87 | 88 | # Flashes errors in a safe way. Joins `full_messages` and truncates 89 | # result to avoid cookies overflow. 90 | def flash_errors!(errors = resource.errors, max_length = 100) 91 | flash[:error] = errors.full_messages.join("\n").truncate(max_length) if errors.any? 92 | end 93 | 94 | # URL to be used in `Location` header & to redirect to after 95 | # resource was created/updated. Default uses `self.class.after_save_action`. 96 | def after_save_url 97 | action = self.class.after_save_action 98 | if action == :index 99 | index_url 100 | else 101 | url_for action: action, id: resource 102 | end 103 | end 104 | 105 | # URL to be used in `Location` header & to redirect after 106 | # resource was destroyed. Default to #index_url. 107 | def after_destroy_url 108 | index_url 109 | end 110 | 111 | # Extracted from #after_save_url and #after_destroy_url because they use 112 | # same value. It's easier to override this urls in one place. 113 | def index_url 114 | url_for action: :index 115 | end 116 | 117 | # Override it to return permited params. By default it returns params 118 | # using `self.class.resource_param_name` and `permitted_attrs` methods. 119 | def resource_params 120 | @_resource_params ||= begin 121 | key = self.class.resource_param_name 122 | params.permit(key => permitted_attrs)[key] || params.class.new.permit! 123 | end 124 | end 125 | 126 | # Default permitted attributes are taken from class method. Override it 127 | # to implement request-based permitted attrs. 128 | def permitted_attrs 129 | self.class.permitted_attrs 130 | end 131 | 132 | # Default authorization implementation. 133 | # Uses `#authorize!` method which is not implemented here 134 | # (use CanCan or other implementation). 135 | def authorize_resource! 136 | action = action_name.to_sym 137 | target = 138 | case action 139 | when :index, :create, :new then self.class.resource_class.new 140 | else resource 141 | end 142 | authorize!(action, target) 143 | end 144 | end 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /lib/rails_stuff/resources_controller/belongs_to.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module ResourcesController 3 | module BelongsTo 4 | class << self 5 | # Builds lambda to use as `#source_relation`. 6 | def source_relation(subject, collection, optional: false, **) 7 | check_subject = :"#{subject}?" 8 | lambda do 9 | if optional && !send(check_subject) 10 | super() 11 | else 12 | send(subject).public_send(collection) 13 | end 14 | end 15 | end 16 | 17 | # Builds lambda to use as `#index_url` 18 | def index_url(subject, *, field: nil, param: nil) 19 | field ||= :"#{subject}_id" 20 | param ||= field 21 | -> { url_for action: :index, param => resource.public_send(field) } 22 | end 23 | end 24 | 25 | # Defines resource helper and source relation 26 | def resource_belongs_to(subject, resource_helper: true, urls: true, **options) 27 | resource_helper(subject) if resource_helper 28 | collection = options[:collection] || resource_class.model_name.plural 29 | source_relation_proc = BelongsTo.source_relation(subject, collection, options) 30 | protected define_method(:source_relation, &source_relation_proc) 31 | protected define_method(:index_url, &BelongsTo.index_url(subject, urls)) if urls 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/rails_stuff/resources_controller/has_scope_helpers.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module ResourcesController 3 | module HasScopeHelpers 4 | # This method overrides default `has_scope` so it include helpers from 5 | # InstanceMethods only when this method is called. 6 | def has_scope(*) 7 | super.tap { include InstanceMethods } 8 | end 9 | 10 | module InstanceMethods 11 | protected 12 | 13 | # Applies `has_scope` scopes to original source. 14 | def source_for_collection 15 | apply_scopes(super) 16 | end 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/rails_stuff/resources_controller/kaminari_helpers.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module ResourcesController 3 | module KaminariHelpers 4 | protected 5 | 6 | # Make source_for_collection use Kaminari-style scopes to paginate relation. 7 | def source_for_collection 8 | super.page(params[:page]).per(params[:per]) 9 | end 10 | 11 | module ConfigMethods 12 | def use_kaminari!(val = true) 13 | @use_kaminari = val 14 | end 15 | 16 | def use_kaminari? 17 | @use_kaminari 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/rails_stuff/resources_controller/resource_helper.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module ResourcesController 3 | # Defines resource helper and finder method. 4 | module ResourceHelper 5 | # Defines protected helper method. Ex. for `:user` 6 | # 7 | # helper_method :user 8 | # 9 | # def user 10 | # @user ||= User.find params[:user_id] 11 | # end 12 | # 13 | # #### Options 14 | # 15 | # - `source` - class name or Proc returning relation, default to `resource_name.classify`. 16 | # - `param` - param name, default to `resource_name.foreign_key` 17 | # 18 | # rubocop:disable CyclomaticComplexity, PerceivedComplexity, AbcSize 19 | def resource_helper(name, param: nil, source: nil, **options) 20 | RailsStuff.deprecation_07.warn('use :source instead of :class') if options.key?(:class) 21 | 22 | param ||= name.to_s.foreign_key.to_sym 23 | define_method("#{name}?") { params.key?(param) } 24 | 25 | ivar = :"@#{name}" 26 | source ||= options[:class] || name.to_s.classify 27 | if source.is_a?(Proc) 28 | define_method(name) do 29 | instance_variable_get(ivar) || 30 | instance_variable_set(ivar, instance_exec(&source).find(params[param])) 31 | end 32 | else 33 | source = Object.const_get(source) unless source.is_a?(Class) 34 | define_method(name) do 35 | instance_variable_get(ivar) || 36 | instance_variable_set(ivar, source.find(params[param])) 37 | end 38 | end 39 | 40 | helper_method name, :"#{name}?" 41 | protected name, :"#{name}?" 42 | end 43 | # rubocop:enable CyclomaticComplexity, PerceivedComplexity, AbcSize 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/rails_stuff/resources_controller/sti_helpers.rb: -------------------------------------------------------------------------------- 1 | require 'active_record/errors' 2 | 3 | module RailsStuff 4 | module ResourcesController 5 | # Helper methods for controllers which works with STI models. 6 | module StiHelpers 7 | class InvalidType < StandardError; end 8 | 9 | extend ActiveSupport::Concern 10 | 11 | module ClassMethods 12 | # Returns hash which is used to get subclass for requested type. 13 | # 14 | # By default it uses `.types_list` or `.descendants` to get list of 15 | # classes and indexes them by class names. 16 | def resource_class_by_type 17 | @resource_class_by_type ||= 18 | if resource_class.respond_to?(:types_list) 19 | resource_class.types_list 20 | else 21 | resource_class.descendants 22 | end.index_by(&:name) 23 | end 24 | 25 | attr_writer :resource_class_by_type 26 | 27 | def resource_class_for(name) 28 | return resource_class unless name 29 | resource_class_by_type[name] || 30 | if use_resource_class_for_invalid_type 31 | resource_class 32 | else 33 | raise(InvalidType, "No type mapped for #{name.inspect}") 34 | end 35 | end 36 | 37 | attr_accessor :use_resource_class_for_invalid_type 38 | 39 | # Class-level accessor to permitted attributes for specisic class. 40 | def permitted_attrs_for 41 | @permitted_attrs_for ||= Hash.new { |h, k| h[k] = [] } 42 | end 43 | 44 | # Permits attrs only for specific class. 45 | def permit_attrs_for(klass, *attrs) 46 | permitted_attrs_for[klass].concat attrs 47 | end 48 | end 49 | 50 | protected 51 | 52 | # Returns model class depending on `type` attr in params. 53 | # If resource is requested by id, it returns its class. 54 | def class_from_request 55 | @class_from_request ||= 56 | if params.key?(:id) 57 | resource.class 58 | else 59 | key = self.class.resource_param_name 60 | name = params.permit(key => [:type])[key].try!(:[], :type) 61 | self.class.resource_class_for(name) 62 | end 63 | end 64 | 65 | # Instantiates object using class_from_request. 66 | def build_resource(attrs = resource_params) 67 | @_resource = super.becomes!(class_from_request) 68 | end 69 | 70 | # Merges default attrs with attrs for specific class. 71 | def permitted_attrs 72 | super + self.class.permitted_attrs_for[class_from_request] 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/rails_stuff/responders.rb: -------------------------------------------------------------------------------- 1 | require 'rails_stuff/responders/turbolinks' 2 | 3 | module RailsStuff 4 | # Here are some useful responders extensions. 5 | # 6 | # Previous versions had responder with SJR helpers, which has been removed. 7 | # To achieve same result define responder with: 8 | # 9 | # class CustomResponder < ActionController::Responder 10 | # include Responders::HttpCacheResponder 11 | # include RailsStuff::Responders::Turbolinks 12 | # 13 | # # SJR: render action on failures, redirect on success 14 | # alias_method :to_js, :to_html 15 | # end 16 | # 17 | module Responders 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/rails_stuff/responders/turbolinks.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module Responders 3 | module Turbolinks 4 | # Bypasses `:turbolinks` from options. 5 | def redirect_to(url, opts = {}) 6 | if !opts.key?(:turbolinks) && options.key?(:turbolinks) 7 | opts[:turbolinks] = options[:turbolinks] 8 | end 9 | super 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/rails_stuff/rspec_helpers.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/array/wrap' 2 | require 'active_support/dependencies/autoload' 3 | 4 | module RailsStuff 5 | # Collection of RSpec configurations and helpers for better experience. 6 | module RSpecHelpers 7 | autoload :Signinable, 'rails_stuff/rspec_helpers/signinable' 8 | 9 | extend self 10 | 11 | # Single endpoint for multiple seups. Use `:only` and `:except` options 12 | # to filter actions. 13 | def setup(only: nil, except: nil) 14 | items = instance_methods.map(&:to_s) - %w[setup] 15 | items -= Array.wrap(except).map(&:to_s) if except 16 | if only 17 | only = Array.wrap(only).map(&:to_s) 18 | items &= only 19 | items += only 20 | end 21 | items.uniq.each { |item| public_send(item) } 22 | end 23 | 24 | %w[ 25 | concurrency 26 | groups/request 27 | groups/feature 28 | matchers/be_valid_js 29 | matchers/redirect_with_turbolinks 30 | ].each do |file| 31 | define_method(file) { require "rails_stuff/rspec_helpers/#{file}" } 32 | end 33 | 34 | # Setup all TestHelpers. 35 | def test_helpers 36 | TestHelpers.setup 37 | end 38 | 39 | # Setups database cleaner to use strategy depending on metadata. 40 | # By default it uses `:transaction` for all examples and `:truncation` 41 | # for features and examples with `concurrent: true`. 42 | # 43 | # Other types can be tuned with `config.cleaner_strategy` hash & 44 | # `config.cleaner_strategy.default`. 45 | def database_cleaner # rubocop:disable AbcSize 46 | return unless defined?(DatabaseCleaner) 47 | ::RSpec.configure do |config| 48 | if config.respond_to?(:use_transactional_fixtures=) 49 | config.use_transactional_fixtures = false 50 | end 51 | config.add_setting :database_cleaner_strategy 52 | config.database_cleaner_strategy = {feature: :truncation} 53 | config.database_cleaner_strategy.default = :transaction 54 | config.add_setting :database_cleaner_options 55 | config.database_cleaner_options = {truncation: {except: %w[spatial_ref_sys]}} 56 | config.add_setting :database_cleaner_args 57 | config.database_cleaner_args = ->(ex) do 58 | strategy = ex.metadata[:concurrent] && :truncation 59 | strategy ||= config.database_cleaner_strategy[ex.metadata[:type]] 60 | options = config.database_cleaner_options[strategy] || {} 61 | [strategy, options] 62 | end 63 | config.around do |ex| 64 | DatabaseCleaner.strategy = config.database_cleaner_args.call(ex) 65 | DatabaseCleaner.cleaning { ex.run } 66 | end 67 | end 68 | end 69 | 70 | # Setups redis to flush db after suite and before each example with 71 | # `flush_redis: :true`. `Rails.redis` client is used by default. 72 | # Can be tuned with `config.redis`. 73 | def redis 74 | ::RSpec.configure do |config| 75 | config.add_setting :redis 76 | config.redis = Rails.redis if defined?(Rails.redis) 77 | config.add_setting :flush_redis_proc 78 | config.flush_redis_proc = ->(*) { Array.wrap(config.redis).each(&:flushdb) } 79 | config.before(flush_redis: true) { instance_exec(&config.flush_redis_proc) } 80 | config.after(:suite) { instance_exec(&config.flush_redis_proc) } 81 | end 82 | end 83 | 84 | # Runs debugger after each failed example with `:debug` tag. 85 | # Uses `pry` by default, this can be configured `config.debugger=`. 86 | def debug 87 | ::RSpec.configure do |config| 88 | config.add_setting :debugger_proc 89 | config.debugger_proc = ->(ex) do 90 | exception = ex.exception 91 | defined?(Pry) ? binding.pry : debugger # rubocop:disable Debugger 92 | end 93 | config.after(debug: true) do |ex| 94 | instance_exec(ex, &config.debugger_proc) if ex.exception 95 | end 96 | end 97 | end 98 | 99 | # Clear logs `tail -f`-safely. 100 | def clear_logs 101 | ::RSpec.configure do |config| 102 | config.add_setting :clear_log_file 103 | config.clear_log_file = Rails.root.join('log', 'test.log') if defined?(Rails.root) 104 | config.add_setting :clear_log_file_proc 105 | config.clear_log_file_proc = ->(file) do 106 | next unless file && File.exist?(file) 107 | FileUtils.cp(file, "#{file}.last") 108 | File.open(file, 'w').close 109 | end 110 | config.after(:suite) do 111 | instance_exec(config.clear_log_file, &config.clear_log_file_proc) unless ENV['KEEP_LOG'] 112 | end 113 | end 114 | end 115 | 116 | # Freeze time for specs with `:frozen_time` metadata. 117 | def frozen_time 118 | ::RSpec.configure do |config| 119 | config.around(frozen_time: true) { |ex| Timecop.freeze { ex.run } } 120 | end 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /lib/rails_stuff/rspec_helpers/concurrency.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/concern' 2 | require 'rails_stuff/test_helpers/concurrency' 3 | 4 | module RailsStuff 5 | module RSpecHelpers 6 | module Concurrency 7 | extend ActiveSupport::Concern 8 | include RailsStuff::TestHelpers::Concurrency 9 | 10 | module ClassMethods 11 | # Defines subject which runs parent's value in multiple threads concurrently. 12 | # Define `thread_args` or `threads_count` with `let` to configure it. 13 | # 14 | # Sets metadata `concurrent: true` so database cleaner uses right strategy. 15 | def concurrent_subject! 16 | metadata[:concurrent] = true 17 | subject do 18 | super_proc = super() 19 | args = defined?(thread_args) && thread_args 20 | args ||= defined?(threads_count) && threads_count 21 | -> { concurrently(args, &super_proc) } 22 | end 23 | end 24 | 25 | # Runs given block in current context and nested context with concurrent subject. 26 | # 27 | # subject { -> { increment_value_once } } 28 | # # This will create 2 examples. One for current contex, and one 29 | # # for current context where subject will run multiple times concurrently. 30 | # check_concurrent do 31 | # it { should change { value }.by(1) } 32 | # end 33 | def check_concurrent(&block) 34 | instance_eval(&block) 35 | context 'running multiple times concurrently' do 36 | concurrent_subject! 37 | instance_eval(&block) 38 | end 39 | end 40 | end 41 | 42 | ::RSpec.configuration.include(self) if defined?(::RSpec) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/rails_stuff/rspec_helpers/groups/feature.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module RSpecHelpers 3 | module Groups 4 | module Feature 5 | def wait_for_ajax 6 | Timeout.timeout(Capybara.default_max_wait_time) do 7 | loop until finished_all_ajax_requests? 8 | end 9 | end 10 | 11 | # Tuned for jQuery, override it if you don't use jQuery. 12 | def finished_all_ajax_requests? 13 | page.evaluate_script('jQuery.active').zero? 14 | end 15 | 16 | def pause 17 | $stderr.write 'Press enter to continue' 18 | $stdin.gets 19 | end 20 | 21 | ::RSpec.configuration.include self, type: :feature 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/rails_stuff/rspec_helpers/groups/request.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/concern' 2 | 3 | module RailsStuff 4 | module RSpecHelpers 5 | module Groups 6 | module Request 7 | extend ActiveSupport::Concern 8 | 9 | included do 10 | # Define default params for ResourcesController. 11 | # 12 | # subject { -> { patch(resource_path, params: params) } } 13 | # let(:resource) { create(:user) } 14 | # let(:resource_params) { {name: 'new name'} } 15 | # # params will be {user: {name: 'new name'}} 16 | if described_class.respond_to?(:resource_param_name) 17 | let(:params) { {described_class.resource_param_name => resource_params} } 18 | end 19 | end 20 | 21 | module ClassMethods 22 | # Adds `referer`, `referer_path` and `headers` with `let`. 23 | # Requires `root_url` 24 | def set_referer 25 | let(:referer) { root_url.sub(%r{/$}, referer_path) } 26 | let(:referer_path) { '/test_referer' } 27 | let(:headers) do 28 | headers = {Referer: referer} 29 | defined?(super) ? super().merge(headers) : headers 30 | end 31 | end 32 | 33 | # Perform simple request to initialize session. 34 | # Useful for `change` matchers. 35 | def init_session 36 | before do 37 | path = defined?(init_session_path) ? init_session_path : '/' 38 | get(path) 39 | end 40 | end 41 | 42 | def with_csrf_protection! 43 | around do |ex| 44 | begin 45 | old = ActionController::Base.allow_forgery_protection 46 | ActionController::Base.allow_forgery_protection = true 47 | ex.run 48 | ensure 49 | ActionController::Base.allow_forgery_protection = old 50 | end 51 | end 52 | let(:csrf_response) do 53 | path = defined?(csrf_response_path) ? csrf_response_path : '/' 54 | get(path) && response.body 55 | end 56 | let(:csrf_param) { csrf_response.match(/meta name="csrf-param" content="([^"]*)"/)[1] } 57 | let(:csrf_token) { csrf_response.match(/" 44 | @scope.assert_operator redirect_expected, :===, redirect_is, message 45 | rescue ActiveSupport::TestCase::Assertion => e 46 | raise XhrFailure, e 47 | end 48 | 49 | ::RSpec::Rails::Matchers::RedirectTo::RedirectTo.prepend(self) if defined?(::RSpec::Rails) 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/rails_stuff/rspec_helpers/signinable.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module RSpecHelpers 3 | module Signinable 4 | # Context-level helper to add before filter to sign in user. 5 | # Adds `current_user` with let if not defined yet or with gven block. 6 | # 7 | # Instance-level `sign_in(user_or_nil)` method must be defined, so 8 | # this module can be used in any of feature, request or controller groups. 9 | # 10 | # sign_in { owner } 11 | # sign_in # will call current_user or define it with nil 12 | def sign_in(&block) 13 | if block || !instance_methods.include?(:current_user) 14 | block ||= ->(*) {} 15 | let(:current_user, &block) 16 | end 17 | before { sign_in(instance_eval { current_user }) } 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/rails_stuff/sort_scope.rb: -------------------------------------------------------------------------------- 1 | require 'has_scope' 2 | 3 | module RailsStuff 4 | # Provides safe and flexible way to sort collections by user's input. 5 | # Uses `has_scope` gem. 6 | # 7 | # Supports different input format, and limits requested fields 8 | # to allowed subset. 9 | module SortScope 10 | # Register type for has_scop that accepts stings, hashes & arrays. 11 | HasScope::ALLOWED_TYPES[:any] = [[String, Hash, Array, Symbol, ActionController::Parameters]] 12 | 13 | # Setups has_scope to order collection by allowed columns. 14 | # Sort column is filtered by SortScope.filter_param method. 15 | # Accepts params: 16 | # 17 | # - `sort=name` 18 | # - `sort=name&sort_desc=true` 19 | # - `sort[name]&sort[order]` 20 | # - `sort[name]&sort[order]=desc 21 | # 22 | # #### Options 23 | # 24 | # - `by` - array of available fields to sort by, 25 | # - `default` - default sort expression, 26 | # - `only` - bypassed to `has_scope` to limit actions (default to `:index`), 27 | # - `order_method` - use custom method to sort instead of `.order`. 28 | # 29 | # rubocop:disable ClassVars 30 | def has_sort_scope(config = {}) 31 | @@_sort_scope_id ||= 0 32 | default = config[:default] || :id 33 | default = default.is_a?(Hash) ? default.stringify_keys : default.to_s 34 | allowed = Array.wrap(config[:by]).map(&:to_s) 35 | only_actions = config.fetch(:only, :index) 36 | order_method = config.fetch(:order_method, :order) 37 | # Counter added into scope name to allow to define multiple scopes in same controller. 38 | has_scope("sort_#{@@_sort_scope_id += 1}", 39 | as: :sort, 40 | default: nil, 41 | allow_blank: true, 42 | only: only_actions, 43 | type: :any, 44 | ) do |c, scope, val| 45 | sort_args = SortScope.filter_param(val, c.params, allowed, default) 46 | c.instance_variable_set(:@current_sort_scope, sort_args) 47 | scope.public_send(order_method, sort_args) 48 | end 49 | end 50 | # rubocop:enable ClassVars 51 | 52 | # It does not use `ClassMethods` similar to `ActiveSupport::Concern` 53 | # due to backward compatibility (SortScope extends controller class). 54 | module InstanceMethods 55 | protected 56 | 57 | def current_sort_scope 58 | @current_sort_scope ||= {} 59 | end 60 | end 61 | 62 | class << self 63 | def extended(base) 64 | base.class_eval do 65 | include InstanceMethods 66 | helper_method :current_sort_scope if respond_to?(:helper_method) 67 | end 68 | end 69 | 70 | # Filters value with whitelist of allowed fields to sort by. 71 | # 72 | # rubocop:disable CyclomaticComplexity, PerceivedComplexity, BlockNesting 73 | def filter_param(val, params, allowed, default = nil) 74 | val ||= default 75 | unless val == default 76 | val = val.to_unsafe_h if val.is_a?(ActionController::Parameters) 77 | val = 78 | if val.is_a?(Hash) 79 | val.each_with_object({}) do |(key, dir), h| 80 | h[key] = (dir == 'desc' ? :desc : :asc) if allowed.include?(key) 81 | end 82 | else 83 | allowed.include?(val) ? val : default 84 | end 85 | end 86 | if val && !val.is_a?(Hash) 87 | val = {val => ParamsParser.parse_boolean(params[:sort_desc]) ? :desc : :asc} 88 | end 89 | val 90 | end 91 | # rubocop:enable CyclomaticComplexity, PerceivedComplexity, BlockNesting 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/rails_stuff/statusable.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | # Basic helpers to work with `status`-like field. 3 | # 4 | # For every status value it provides: 5 | # 6 | # - scopes with status name (eg. `.rejected`, '.not_rejected') 7 | # - inquiry method to check status (eg. `#rejected?`) 8 | # - bang method to update status (eg. `#rejected!`) 9 | # 10 | # It also provides: 11 | # 12 | # - translation helpers (`acttivemodel_translation` gem required) 13 | # - inclusion validator 14 | # - string/symbol agnostic `#status=` 15 | # - `#status_sym` 16 | # - `status_select_options` helper. 17 | # 18 | # It supports mapped statuses, just provide a hash with 19 | # `{status_name => interna_value}` instead of array of statuses. 20 | module Statusable 21 | autoload :Helper, 'rails_stuff/statusable/helper' 22 | autoload :Builder, 'rails_stuff/statusable/builder' 23 | autoload :MappedHelper, 'rails_stuff/statusable/mapped_helper' 24 | autoload :MappedBuilder, 'rails_stuff/statusable/mapped_builder' 25 | 26 | class << self 27 | # Fetches statuses list from model constants. See #has_status_field. 28 | def fetch_statuses(model, field) 29 | const_name = "#{field.to_s.pluralize.upcase}_MAPPING" 30 | const_name = field.to_s.pluralize.upcase unless model.const_defined?(const_name) 31 | model.const_get(const_name) 32 | end 33 | end 34 | 35 | # Defines all helpers working with `field` (default to `status`). 36 | # List of values can be given as second argument, otherwise it'll 37 | # be read from consts using pluralized name of `field` 38 | # (eg. default to `STATUSES_MAPPING`, `STATUSES`). 39 | # 40 | # #### Options 41 | # 42 | # - `prefix` - used to prefix value-named helpers. 43 | # 44 | # # this defines #shipped?, #shipped! methods 45 | # has_status_field :delivery_status, %i(shipped delivered) 46 | # 47 | # # this defines #delivery_shipped?, #delivery_shipped! methods 48 | # has_status_field :delivery_status, %i(shipped delivered), prefix: :delivery_ 49 | # 50 | # - `suffix` - similar to `prefix`. 51 | # 52 | # - `validate` - additional options for validatior. `false` to disable it. 53 | # 54 | # - `helper` - custom helper class. 55 | # 56 | # - `builder` - custom methods builder class. 57 | # 58 | # - `mapping` - shortcut for `statuses` param (see examples). 59 | # 60 | # Pass block to customize methods generation process (see Builder for available methods): 61 | # 62 | # # This will define only scope with status names, but no other methods. 63 | # has_status_field do |builder| 64 | # builder.value_scopes 65 | # end 66 | # 67 | # Examples: 68 | # 69 | # # Setup #status field, take list from STATUSES or STATUSES_MAPPING constant. 70 | # has_status_field 71 | # # Custom field, take kist from KINDS or KINDS_MAPPING: 72 | # has_status_field :kind 73 | # # Inline statuses list and options: 74 | # has_status_field :status, %i(one two), prefix: :my_ 75 | # has_status_field :status, {one: 1, two: 2}, prefix: :my_ 76 | # has_status_field :status, mapping: {one: 1, two: 2}, prefix: :my_ 77 | # # Mapped field without options: 78 | # has_status_field :status, {one: 1, two: 2}, {} 79 | # has_status_field :status, mapping: {one: 1, two: 2} 80 | # 81 | def has_status_field(field = :status, statuses = nil, mapping: nil, **options) 82 | statuses ||= mapping || Statusable.fetch_statuses(self, field) 83 | is_mapped = statuses.is_a?(Hash) 84 | helper_class = options.fetch(:helper) { is_mapped ? MappedHelper : Helper } 85 | helper = helper_class.new(self, field, statuses) 86 | helper.attach 87 | builder_class = options.fetch(:builder) { is_mapped ? MappedBuilder : Builder } 88 | if builder_class 89 | builder = builder_class.new(helper, options) 90 | block_given? ? yield(builder) : builder.generate 91 | end 92 | end 93 | 94 | # Module to hold generated methods. Single for all status fields in model. 95 | def statusable_methods 96 | # Include generated methods with a module, not right in class. 97 | @statusable_methods ||= Module.new.tap do |m| 98 | m.const_set :ClassMethods, Module.new 99 | include m 100 | extend m::ClassMethods 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/rails_stuff/statusable/builder.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module Statusable 3 | # Basic builder for statuses list. Generates methods and scopes. 4 | class Builder 5 | attr_reader :helper, :options, :prefix, :suffix 6 | delegate :model, :field, :list, to: :helper 7 | delegate :define_scope, :define_method, :define_class_method, to: :helper 8 | 9 | def initialize(helper, **options) 10 | @helper = helper 11 | @options = options 12 | @prefix = options[:prefix] 13 | @suffix = options[:suffix] 14 | end 15 | 16 | def generate 17 | validations if options.fetch(:validate, true) 18 | field_accessor 19 | field_scope 20 | value_scopes 21 | value_accessors 22 | translation_helpers 23 | end 24 | 25 | def validations 26 | model.validates_inclusion_of field, 27 | {in: valid_list}.merge!(options.fetch(:validate, {})) 28 | end 29 | 30 | # Field reader returns string, so we stringify list for validation. 31 | def valid_list 32 | list.map(&:to_s) 33 | end 34 | 35 | # Yields every status with it's database value into block. 36 | def each_status 37 | list.each { |x| yield x, x.to_s } 38 | end 39 | 40 | # Wraps status name with prefix and suffix. 41 | def status_method_name(status) 42 | "#{prefix}#{status}#{suffix}" 43 | end 44 | 45 | # Scope with given status. Useful for has_scope. 46 | def field_scope 47 | field = self.field 48 | define_scope "with_#{field}", ->(status) { where(field => status) } 49 | end 50 | 51 | # Status accessors for every status. 52 | def value_accessors 53 | each_status do |status, value| 54 | value_accessor status, value 55 | end 56 | end 57 | 58 | # Scopes for every status. 59 | def value_scopes 60 | field = self.field 61 | each_status do |status, value| 62 | define_scope status_method_name(status), -> { where(field => value) } 63 | define_scope "not_#{status_method_name(status)}", -> { where.not(field => value) } 64 | end 65 | end 66 | 67 | # Generates methods for specific value. 68 | def value_accessor(status, value) 69 | field = self.field 70 | 71 | # Shortcut to check status. 72 | define_method "#{status_method_name(status)}?" do 73 | # Access raw value, 'cause reader can be overriden. 74 | self[field] == value 75 | end 76 | 77 | # Shortcut to update status. 78 | define_method "#{status_method_name(status)}!" do 79 | update!(field => value) 80 | end 81 | end 82 | 83 | def field_accessor 84 | field_reader 85 | field_writer 86 | end 87 | 88 | # Make field accept sympbols. 89 | def field_writer 90 | define_method "#{field}=" do |val| 91 | val = val.to_s if val.is_a?(Symbol) 92 | super(val) 93 | end 94 | end 95 | 96 | # Status as symbol. 97 | def field_reader 98 | field = self.field 99 | define_method "#{field}_sym" do 100 | val = self[field] 101 | val && val.to_sym 102 | end 103 | end 104 | 105 | def translation_helpers 106 | field = self.field 107 | define_method "#{field}_name" do 108 | val = send(field) 109 | self.class.t(".#{field}_name.#{val}") if val 110 | end 111 | end 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/rails_stuff/statusable/helper.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module Statusable 3 | # Class to hold helper methods for statusable field. 4 | # 5 | # Order.has_status_field :status, %i(pending complete) 6 | # Order.statuses.list # => %(pending complete) 7 | # # ... 8 | class Helper 9 | attr_reader :model, :field, :list 10 | 11 | def initialize(model, field, statuses) 12 | @model = model 13 | @field = field.freeze 14 | @list = statuses.freeze 15 | end 16 | 17 | def translate(status) 18 | model.t(".#{field}_name.#{status}") if status 19 | end 20 | 21 | alias_method :t, :translate 22 | 23 | # Returns array compatible with select_options helper. 24 | def select_options(only: nil, except: nil) 25 | only ||= list 26 | only -= except if except 27 | only.map { |x| [translate(x), x] } 28 | end 29 | 30 | # Generate class method in model to access helper. 31 | def attach(method_name = field.to_s.pluralize) 32 | helper = self 33 | define_class_method(method_name) { helper } 34 | end 35 | 36 | # Rails 4 doesn't use `instance_exec` for scopes, so we do it manually. 37 | # For Rails 5 it's just use `.scope`. 38 | def define_scope(name, body) 39 | if RailsStuff.rails4? 40 | model.singleton_class.send(:define_method, name) do |*args| 41 | all.scoping { instance_exec(*args, &body) } || all 42 | end 43 | else 44 | model.scope(name, body) 45 | end 46 | end 47 | 48 | def define_method(method, &block) 49 | methods_module.send(:define_method, method, &block) 50 | end 51 | 52 | def define_class_method(method, &block) 53 | methods_module::ClassMethods.send(:define_method, method, &block) 54 | end 55 | 56 | protected 57 | 58 | # Module to hold generated methods. 59 | def methods_module 60 | model.statusable_methods 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/rails_stuff/statusable/mapped_builder.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module Statusable 3 | # Generates methods and scopes when status names are mapped to internal values. 4 | class MappedBuilder < Statusable::Builder 5 | delegate :mapping, :inverse_mapping, to: :helper 6 | 7 | # Field reader returns mapped value, so we don't need to stringify list. 8 | alias_method :valid_list, :list 9 | 10 | def each_status(&block) 11 | mapping.each(&block) 12 | end 13 | 14 | # Scope with given status. Useful for has_scope. 15 | def field_scope 16 | field = self.field 17 | helper = self.helper 18 | define_scope "with_#{field}", ->(status) { where(field => helper.map(status)) } 19 | end 20 | 21 | def field_reader 22 | field = self.field 23 | helper = self.helper 24 | 25 | # Returns status name. 26 | define_method field do |original = false| 27 | val = super() 28 | original || !val ? val : helper.unmap(val) 29 | end 30 | 31 | # Status as symbol. 32 | define_method "#{field}_sym" do 33 | val = public_send(field) 34 | val && val.to_sym 35 | end 36 | end 37 | 38 | def field_writer 39 | helper = self.helper 40 | # Make field accept sympbols. 41 | define_method "#{field}=" do |val| 42 | val = val.to_s if val.is_a?(Symbol) 43 | super(helper.map(val)) 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/rails_stuff/statusable/mapped_helper.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module Statusable 3 | # Helper to hold 4 | class MappedHelper < Helper 5 | attr_reader :mapping, :inverse_mapping, :indifferent_mapping 6 | 7 | def initialize(*) 8 | super 9 | @mapping = @list 10 | @indifferent_mapping = mapping.with_indifferent_access 11 | @list = mapping.keys.freeze 12 | @inverse_mapping = mapping.invert.freeze 13 | end 14 | 15 | def select_options(original: false, only: nil, except: nil) 16 | return super(only: only, except: except) unless original 17 | only ||= mapping_values 18 | only -= except if except 19 | only.map { |x| [translate(inverse_mapping.fetch(x)), x] } 20 | end 21 | 22 | def mapping_values 23 | @mapping_values ||= mapping.values 24 | end 25 | 26 | def map(val) 27 | map_with(indifferent_mapping, val) 28 | end 29 | 30 | def unmap(val) 31 | map_with(inverse_mapping, val) 32 | end 33 | 34 | protected 35 | 36 | # Maps single value or array with given map. 37 | def map_with(map, val) 38 | if val.is_a?(Array) 39 | val.map { |x| map.fetch(x, x) } 40 | else 41 | map.fetch(val, val) 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/rails_stuff/strong_parameters.rb: -------------------------------------------------------------------------------- 1 | ActionController::Parameters.class_eval do 2 | # Permits and then checks all fields for presence. 3 | def require_permitted(*fields) 4 | permit(*fields).tap { |permitted| fields.each { |f| permitted.require(f) } } 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/rails_stuff/test_helpers.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/array/wrap' 2 | 3 | module RailsStuff 4 | # Collection of RSpec configurations and helpers for better experience. 5 | module TestHelpers 6 | extend self 7 | 8 | def setup(only: nil, except: nil) 9 | items = instance_methods.map(&:to_s) - %w[setup] 10 | items -= Array.wrap(except).map(&:to_s) if except 11 | if only 12 | only = Array.wrap(only).map(&:to_s) 13 | items &= only 14 | items += only 15 | end 16 | items.each { |item| public_send(item) } 17 | end 18 | 19 | %w[ 20 | integration_session 21 | response 22 | ].each do |file| 23 | define_method(file.tr('/', '_')) { require "rails_stuff/test_helpers/#{file}" } 24 | end 25 | 26 | # Make BigDecimal`s more readable. 27 | def big_decimal 28 | require 'bigdecimal' 29 | BigDecimal.class_eval do 30 | alias_method :inspect_orig, :inspect 31 | alias_method :inspect, :to_s 32 | end 33 | end 34 | 35 | # Raise errors from failed threads. 36 | def thread 37 | Thread.abort_on_exception = true 38 | end 39 | 40 | # Raise all translation errors, to not miss any of translations. 41 | # Make sure to set `config.action_view.raise_on_missing_translations = true` in 42 | # `config/environments/test.rb` yourself. 43 | def i18n 44 | return unless defined?(I18n) 45 | I18n.config.exception_handler = ->(exception, _locale, _key, _options) do 46 | raise exception.respond_to?(:to_exception) ? exception.to_exception : exception 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/rails_stuff/test_helpers/concurrency.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/array/wrap' 2 | 3 | module RailsStuff 4 | module TestHelpers 5 | module Concurrency 6 | class << self 7 | # Default threads count 8 | attr_accessor :threads_count 9 | end 10 | @threads_count = 3 11 | 12 | extend self 13 | 14 | # Runs block concurrently in separate threads. 15 | # Pass array of args arrays to run each thread with its own arguments. 16 | # Or pass Integer to run specified threads count with same arguments. 17 | # Default is to run Concurrency.threads_count threads. 18 | # 19 | # concurrently { do_something } 20 | # concurrently(5) { do_something } 21 | # concurrently([[1, opt: true], [2, opt: false]]) do |arg, **options| 22 | # do_something(arg, options) 23 | # end 24 | # # It'll automatically wrap single args into Array: 25 | # concurrently(1, 2, {opt: true}, {opt: false}, [1, opt: false]) { ... } 26 | # 27 | def concurrently(thread_args = nil) 28 | thread_args ||= Concurrency.threads_count 29 | threads = 30 | case thread_args 31 | when Integer 32 | Array.new(thread_args) { Thread.new { yield } } 33 | else 34 | thread_args.map { |args| Thread.new { yield(*Array.wrap(args)) } } 35 | end 36 | threads.each(&:join) 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/rails_stuff/test_helpers/integration_session.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | module TestHelpers 3 | module IntegrationSession 4 | # Return smth usable instead of status code. 5 | def process(*) 6 | super 7 | response 8 | end 9 | 10 | # Set host explicitly, because it can change after request. 11 | def default_url_options 12 | super.merge(host: host) 13 | end 14 | 15 | ActionDispatch::Integration::Session.prepend(self) if defined?(ActionDispatch) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/rails_stuff/test_helpers/response.rb: -------------------------------------------------------------------------------- 1 | require 'hashie' 2 | 3 | module RailsStuff 4 | module TestHelpers 5 | module Response 6 | class << self 7 | # Return `Hashie::Mash` for a given object. When `Array` is given 8 | # it is mapped to mash recursievly. 9 | def prepare_json_object(object) 10 | case object 11 | when Hash then Hashie::Mash.new(object) 12 | when Array then object.map(&method(__callee__)) 13 | else object 14 | end 15 | end 16 | end 17 | 18 | # Missing enquirers. 19 | { 20 | unprocessable_entity: 422, 21 | no_content: 204, 22 | }.each do |name, code| 23 | define_method("#{name}?") { status == code } 24 | end 25 | 26 | # Easy access to json bodies. It parses and return `Hashie::Mash`'es, so 27 | # properties can be accessed via method calls: 28 | # 29 | # response.json_body.order.items.id 30 | # # note that hash methods are still present: 31 | # response.json_body.order[:key] # instead of order.key 32 | def json_body 33 | @json_body ||= Response.prepare_json_object(JSON.parse(body)) 34 | end 35 | 36 | # Makes it easier to debug failed specs. 37 | def inspect 38 | "" 39 | end 40 | 41 | ActionDispatch::TestResponse.send(:include, self) if defined?(ActionDispatch) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/rails_stuff/transform_attrs.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | # Generates writers which apply given transfromer for values. 3 | module TransformAttrs 4 | class << self 5 | def transformations 6 | @transformations ||= {} 7 | end 8 | 9 | # Register new transformation with given block. 10 | # 11 | # number_regexp = /\A\d+\z/ 12 | # RailsStuff::TransformAttrs.register :phone do |val| 13 | # if val && val.to_s =~ number_regexp 14 | # ActiveSupport::NumberHelper.number_to_phone(val) 15 | # else 16 | # val 17 | # end 18 | # end 19 | def register(name, &block) 20 | transformations[name] = block 21 | end 22 | 23 | def fetch_block(ids) 24 | if ids.is_a?(Array) 25 | blocks = ids.map { |x| transformations.fetch(x) } 26 | ->(val) { blocks.reduce(val) { |x, block| block.call(x) } } 27 | else 28 | transformations.fetch(ids) 29 | end 30 | end 31 | end 32 | 33 | register(:strip) { |val| val && val.to_s.strip } 34 | register(:nullify, &:presence) 35 | register(:strip_and_nullify) { |val| val && val.to_s.strip.presence } 36 | 37 | # Options: 38 | # 39 | # - `with` - use built-in transfromers from TransformAttrs.transformations. 40 | # Add new transformations with TransformAttrs.register. 41 | # - `new_module` - create new module for generated methods. 42 | # Accepts `:prepend` or `:include`. By default it uses single module 43 | # which is prepended. 44 | # 45 | # transform_attrs(:attr1, :attr2) { |x| x.to_s.downcase } 46 | # transform_attrs(:attr3, &:presence) # to nullify blanks 47 | # transform_attrs(:attr4, with: :strip) 48 | # transform_attrs(:attr4, with: [:strip, :nullify]) 49 | # transform_attrs(:attr5, new_module: :include) 50 | def transform_attrs(*attrs, with: nil, new_module: false, &block) 51 | block ||= TransformAttrs.fetch_block(with) 52 | mod = Module.new.tap { |x| public_send(new_module, x) } if new_module 53 | mod ||= transform_attrs_methods 54 | mod.class_eval do 55 | attrs.each do |attr| 56 | define_method("#{attr}=") { |val| super(block[val]) } 57 | end 58 | end 59 | end 60 | 61 | # Module to store generated methods, so they can be overriden in model. 62 | def transform_attrs_methods 63 | @transform_attrs_methods ||= Module.new.tap { |x| prepend x } 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/rails_stuff/types_tracker.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/class/attribute' 2 | 3 | module RailsStuff 4 | # Adds `types_list` method which tracks all descendants. 5 | # Also allows to remove any of descendants from this list. 6 | # Useful for STI models to track all available types. 7 | # 8 | # Use with RequireNested to preload all nested classes. 9 | module TypesTracker 10 | class << self 11 | def extended(base) 12 | base.class_attribute :types_list, instance_accessor: false 13 | base.types_list = types_list_class.new 14 | base.instance_variable_set(:@_types_tracker_base, base) 15 | end 16 | 17 | # Class for `types_list`. Default to `Array`. You can override it 18 | # for all models, or assign new value to specific model 19 | # via `lypes_list=` right after extending. 20 | attr_accessor :types_list_class 21 | end 22 | 23 | self.types_list_class = Array 24 | 25 | # Add `self` to `types_list`. Defines scope for ActiveRecord models. 26 | def register_type(*args) 27 | if types_list.respond_to?(:add) 28 | types_list.add self, *args 29 | else 30 | types_list << self 31 | end 32 | if types_tracker_base.respond_to?(:scope) && 33 | !types_tracker_base.respond_to?(model_name.element) 34 | type_name = name 35 | types_tracker_base.scope model_name.element, -> { where(type: type_name) } 36 | end 37 | end 38 | 39 | # Remove `self` from `types_list`. It doesnt remove generated scope 40 | # from ActiveRecord models, 'cause it potentialy can remove other methods. 41 | def unregister_type 42 | types_list.delete self 43 | end 44 | 45 | # Tracks all descendants automatically. 46 | def inherited(base) 47 | super 48 | base.register_type 49 | end 50 | 51 | # Class that was initilly extended with TypesTracker. 52 | def types_tracker_base 53 | @_types_tracker_base || superclass.types_tracker_base 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/rails_stuff/url_for_keeping_params.rb: -------------------------------------------------------------------------------- 1 | ActionDispatch::Routing::UrlFor.class_eval do 2 | # Safe way to generate url keeping params from request. 3 | # 4 | # It requires `request` to be present. Please don't use it in mailers 5 | # or in other places where it's not supposed to. 6 | def url_for_keeping_params(params) 7 | url_for params: request.query_parameters.merge(params) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/rails_stuff/version.rb: -------------------------------------------------------------------------------- 1 | module RailsStuff 2 | def self.gem_version 3 | Gem::Version.new VERSION::STRING 4 | end 5 | 6 | module VERSION #:nodoc: 7 | MAJOR = 0 8 | MINOR = 6 9 | TINY = 0 10 | PRE = nil 11 | 12 | STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.') 13 | 14 | class << self 15 | def to_s 16 | STRING 17 | end 18 | 19 | def inspect 20 | STRING.inspect 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /rails_stuff.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'rails_stuff/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'rails_stuff' 7 | spec.version = RailsStuff::VERSION::STRING 8 | spec.authors = ['Max Melentiev'] 9 | spec.email = ['melentievm@gmail.com'] 10 | 11 | spec.summary = 'Collection of useful modules for Rails' 12 | spec.homepage = 'https://github.com/printercu/rails_stuff' 13 | spec.license = 'MIT' 14 | 15 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(spec)/}) } 16 | spec.bindir = 'exe' 17 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 18 | spec.require_paths = ['lib'] 19 | 20 | spec.add_dependency 'activesupport', '> 4.0', '< 6.0' 21 | 22 | spec.add_development_dependency 'bundler', '~> 1.8' 23 | spec.add_development_dependency 'rake', '~> 10.0' 24 | end 25 | -------------------------------------------------------------------------------- /spec/integration/requests/site/forms_spec.rb: -------------------------------------------------------------------------------- 1 | require 'integration_helper' 2 | require 'support/shared/statusable' 3 | 4 | RSpec.describe Site::FormsController, type: :request do 5 | describe '#index' do 6 | subject { get controller_path } 7 | 8 | # For rails-4 9 | def document_root_element 10 | html_document.root 11 | end 12 | 13 | include_context 'statusable' 14 | before { add_translations(status: Order.statuses.list + Customer.statuses.list) } 15 | 16 | it 'has forms with statusable selects' do 17 | should be_ok 18 | # puts response.body 19 | 20 | assert_select '#new_order' do 21 | assert_select '[name="order[status]"]' do 22 | assert_select 'option', 2 do 23 | assert_select '[value=pending][selected=selected]', 'pending_en' 24 | assert_select '[value=accepted]', 'accepted_en' 25 | end 26 | end 27 | end 28 | 29 | assert_select '#order_2' do 30 | assert_select '[name="order[status]"]' do 31 | assert_select 'option', 2 do 32 | assert_select '[value="3"][selected=selected]', 'delivered_en' 33 | assert_select '[value="2"]', 'accepted_en' 34 | end 35 | end 36 | end 37 | 38 | assert_select '#new_customer' do 39 | assert_select '[name="customer[status]"]' do 40 | assert_select 'option', 2 do 41 | assert_select 'option[value=banned][selected=selected]', 'banned_en' 42 | assert_select 'option[value=verified]', 'verified_en' 43 | end 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/integration/requests/site/projects_spec.rb: -------------------------------------------------------------------------------- 1 | require 'integration_helper' 2 | 3 | RSpec.describe Site::ProjectsController, :db_cleaner, type: :request do 4 | let(:user) { User.create_default! } 5 | let(:user_id) { user.id } 6 | let(:resource) { Project.create_default!(user: user) } 7 | let(:resource_id) { resource.id } 8 | let(:other_user) { User.create_default! } 9 | let(:other_resource) { Project.create_default!(user: other_user) } 10 | let(:controller_resource) { controller.send :resource } 11 | 12 | describe '#index' do 13 | subject { get controller_path(user_id: user.id), params: params } 14 | let(:params) { {} } 15 | before { resource && other_resource } 16 | 17 | it 'renders index, and limits collection to parent`s resources' do 18 | should render_template :index 19 | expect(controller.send(:collection).all.to_a).to eq [resource] 20 | end 21 | 22 | context 'when pagination params are given' do 23 | let(:params) { {user_id: user.id, page: 10, per: 2} } 24 | it 'paginates collection' do 25 | should render_template :index 26 | collection = controller.send(:collection) 27 | expect(collection.offset_value).to eq 18 28 | expect(collection.limit_value).to eq 2 29 | end 30 | end 31 | 32 | context 'when parent is not found' do 33 | before { user.destroy } 34 | it { should be_not_found } 35 | end 36 | end 37 | 38 | describe '#create' do 39 | subject { -> { post controller_path(user_id: user_id), params: params } } 40 | let(:resource_params) do 41 | { 42 | name: 'New project', 43 | user_id: other_user.id, 44 | department: 'D', 45 | company: 'C', 46 | type: 'Project::Internal', 47 | } 48 | end 49 | 50 | context 'when create succeeds' do 51 | it 'redirects to created user path' do 52 | should change { user.projects.count }.by(1) 53 | resource = user.projects.last 54 | expect(resource.attributes).to include( 55 | 'name' => 'New project', 56 | 'department' => 'D', 57 | 'company' => nil, 58 | ) 59 | should redirect_to site_project_path(resource) 60 | end 61 | 62 | it 'respects per-type allowed attributes' do 63 | resource_params[:type] = 'Project::External' 64 | should change { user.projects.count }.by(1) 65 | resource = user.projects.last 66 | should redirect_to site_project_path(resource) 67 | expect(resource.attributes).to include( 68 | 'name' => 'New project', 69 | 'department' => nil, 70 | 'company' => 'C', 71 | ) 72 | end 73 | end 74 | 75 | context 'when create fails' do 76 | let(:resource_params) { super().except(:name) } 77 | 78 | it 'renders index' do 79 | should_not change(Project, :count) 80 | expect(response).to render_template :index 81 | expect(controller_resource.attributes).to include( 82 | 'user_id' => user.id, 83 | 'name' => nil, 84 | 'department' => 'D', 85 | 'company' => nil, 86 | ) 87 | end 88 | end 89 | 90 | context 'when invalid type is requested' do 91 | let(:resource_params) { super().merge(type: 'Project::Hidden') } 92 | its(:call) { should be_unprocessable_entity } 93 | end 94 | 95 | context 'when parent is not found' do 96 | let(:user_id) { -1 } 97 | its(:call) { should be_not_found } 98 | end 99 | end 100 | 101 | describe '#update' do 102 | subject { patch(resource_path, params: params) } 103 | let(:resource_params) do 104 | { 105 | name: 'New project', 106 | user_id: other_user.id, 107 | department: 'D', 108 | company: 'C', 109 | type: 'Project::Hidden', 110 | } 111 | end 112 | 113 | context 'when update succeeds' do 114 | it 'redirects to index' do 115 | expect { should be_redirect }. 116 | to change { resource.reload.attributes.slice('name', 'department', 'company', 'type') }. 117 | to( 118 | 'name' => 'New project', 119 | 'department' => nil, 120 | 'company' => 'C', 121 | 'type' => 'Project::External', 122 | ) 123 | should redirect_to site_project_path(user) 124 | end 125 | 126 | context 'when resource is of other type' do 127 | let(:resource) { super().becomes!(Project::Internal).tap(&:save!) } 128 | 129 | it 'respects per-type allowed attributes' do 130 | expect { should redirect_to site_project_path(user) }. 131 | to change { resource.reload.attributes.slice('name', 'department', 'company', 'type') }. 132 | to( 133 | 'name' => 'New project', 134 | 'department' => 'D', 135 | 'company' => nil, 136 | 'type' => 'Project::Internal', 137 | ) 138 | end 139 | end 140 | end 141 | 142 | context 'when update fails' do 143 | let(:resource_params) { super().merge(name: '') } 144 | 145 | it 'renders edit' do 146 | expect { should render_template :edit }. 147 | to_not change { resource.reload.attributes } 148 | expect(controller_resource.attributes).to include( 149 | 'user_id' => user.id, 150 | 'name' => '', 151 | 'department' => nil, 152 | 'company' => 'C', 153 | 'type' => 'Project::External', 154 | ) 155 | end 156 | end 157 | end 158 | 159 | describe '#destroy' do 160 | subject { -> { delete(resource_path) } } 161 | it { should change { resource.class.exists?(resource.id) }.to false } 162 | its(:call) { should redirect_to site_user_projects_path(user) } 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /spec/integration/requests/site/users_spec.rb: -------------------------------------------------------------------------------- 1 | require 'integration_helper' 2 | 3 | RSpec.describe Site::UsersController, :db_cleaner, type: :request do 4 | let(:resource) { User.create_default! } 5 | let(:resource_id) { resource.id } 6 | let(:other_resource) { User.create_default!(email: 'other@example.com') } 7 | let(:controller_resource) { controller.send :resource } 8 | 9 | describe '#index' do 10 | subject { get controller_path, params: params } 11 | let(:params) { {} } 12 | let(:collection) do 13 | should render_template :index 14 | controller.send(:collection) 15 | end 16 | let!(:resources) { [resource, other_resource] } 17 | 18 | it 'renders index' do 19 | expect(collection.all.to_a).to eq resources 20 | end 21 | 22 | context 'when pagination params are given' do 23 | let(:params) { {page: 10, per: 2} } 24 | it 'paginates collection' do 25 | expect(collection.offset_value).to eq 18 26 | expect(collection.limit_value).to eq 2 27 | end 28 | 29 | context 'and param for has_scope is given' do 30 | let(:params) { super().merge(by_email: resource.email, page: 1) } 31 | it 'paginates & filters collection' do 32 | expect(collection.all.to_a).to eq [resource] 33 | expect(collection.offset_value).to eq 0 34 | expect(collection.limit_value).to eq 2 35 | end 36 | end 37 | end 38 | 39 | context 'when param for has_scope is given' do 40 | let(:params) { {by_email: resource.email} } 41 | it 'filters collection' do 42 | expect(collection.all.to_a).to eq [resource] 43 | end 44 | end 45 | end 46 | 47 | describe '#show' do 48 | subject { get resource_path } 49 | 50 | it 'finds resource and renders template' do 51 | should render_template :show 52 | expect(controller_resource).to eq resource 53 | end 54 | 55 | context 'when resource is not found' do 56 | before { resource.destroy } 57 | it { should be_not_found } 58 | end 59 | end 60 | 61 | describe '#new' do 62 | subject { get controller_path(action: :new), params: params } 63 | let(:resource_params) { {name: 'New name', admin: true} } 64 | 65 | it 'initializes new resource' do 66 | should render_template :new 67 | expect(controller_resource).to be_instance_of User 68 | expect(controller_resource.attributes).to include( 69 | 'name' => 'New name', 70 | 'admin' => false, 71 | ) 72 | end 73 | end 74 | 75 | describe '#create' do 76 | subject { post controller_path, params: params } 77 | let(:resource_params) { {name: 'New name', email: 'test', admin: true} } 78 | 79 | it 'redirects to created user path' do 80 | expect { should be_redirect }.to change(User, :count).by(1) 81 | resource = User.last 82 | expect(resource.name).to eq 'New name' 83 | should redirect_to site_user_path(resource) 84 | end 85 | 86 | context 'when create fails' do 87 | let(:resource_params) { super().except(:email) } 88 | 89 | it 'renders :new' do 90 | expect { should render_template :new }.to_not change(User, :count) 91 | expect(controller_resource.attributes).to include( 92 | 'name' => 'New name', 93 | 'admin' => false, 94 | ) 95 | end 96 | end 97 | end 98 | 99 | describe '#edit' do 100 | subject { get resource_path(action: :edit) } 101 | 102 | it 'finds resource and renders template' do 103 | should render_template :edit 104 | expect(controller_resource).to eq resource 105 | end 106 | 107 | context 'when resource is not found' do 108 | before { resource.destroy } 109 | it { should be_not_found } 110 | end 111 | end 112 | 113 | describe '#update' do 114 | subject { patch resource_path, params: params } 115 | let(:resource_params) { {name: 'New name', admin: true} } 116 | 117 | context 'when update succeeds' do 118 | it 'redirects to user path' do 119 | expect { should be_redirect }. 120 | to change { resource.reload.name }.to('New name') 121 | expect(resource.admin).to eq false 122 | should redirect_to site_user_path(resource) 123 | end 124 | end 125 | 126 | context 'when update fails' do 127 | let(:resource_params) { {name: '', admin: true} } 128 | 129 | it 'renders :edit' do 130 | expect { should render_template :edit }. 131 | to_not change { resource.reload.name } 132 | expect(controller_resource.attributes).to include( 133 | 'name' => '', 134 | 'admin' => false, 135 | ) 136 | end 137 | end 138 | 139 | context 'when resource is not found' do 140 | before { resource.destroy } 141 | it { should be_not_found } 142 | end 143 | end 144 | 145 | describe '#destroy' do 146 | subject { delete resource_path } 147 | 148 | context 'when destroy succeeds' do 149 | it 'redirects to index' do 150 | expect { should redirect_to site_users_path }. 151 | to change { User.find_by id: resource.id }.to(nil) 152 | end 153 | end 154 | 155 | context 'when destroy fails' do 156 | before do 157 | expect_any_instance_of(User).to receive(:destroy) do |instance| 158 | instance.errors.add :base, :forbidden 159 | end 160 | end 161 | 162 | it 'redirects to index and flashes error' do 163 | expect { should redirect_to site_users_path }. 164 | to_not change { User.find_by id: resource.id } 165 | expect(controller_resource.errors[:base]).to be_present 166 | expect(flash[:error]).to be_present 167 | end 168 | end 169 | 170 | context 'when resource is not found' do 171 | before { resource.destroy } 172 | it { should be_not_found } 173 | end 174 | end 175 | 176 | describe '.action_methods' do 177 | subject { described_class.action_methods } 178 | it { should_not include 'source_for_collection' } 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /spec/integration_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | require 'rails/engine' 3 | require 'rails/railtie' 4 | require 'support/active_record' 5 | require 'kaminari' 6 | require 'has_scope' 7 | require 'activemodel_translation/helper' 8 | Kaminari::Hooks.init 9 | 10 | ENV['RAILS_ENV'] = 'test' 11 | class TestApplication < Rails::Application 12 | config.eager_load = false 13 | config.log_level = :debug 14 | secrets[:secret_key_base] = 'test' 15 | 16 | config.action_dispatch.rescue_responses.merge!( 17 | 'RailsStuff::ResourcesController::StiHelpers::InvalidType' => :unprocessable_entity, 18 | ) 19 | end 20 | Rails.application.initialize! 21 | 22 | # # Routes 23 | Rails.application.routes.draw do 24 | namespace :site do 25 | resources :users do 26 | resources :projects, shallow: true 27 | end 28 | resources :forms, only: :index 29 | end 30 | end 31 | 32 | RailsStuff::TestHelpers.setup only: %w[integration_session response] 33 | RailsStuff::RSpecHelpers.setup only: %w[groups/request clear_logs] 34 | 35 | # # Models 36 | module BuildDefault 37 | def build_default(**attrs) 38 | new(const_get(:DEFAULT_ATTRS).merge(attrs)) 39 | end 40 | 41 | def create_default!(**attrs) 42 | build_default(attrs).tap(&:save) 43 | end 44 | end 45 | 46 | class User < ApplicationRecord 47 | has_many :projects, dependent: :nullify 48 | validates_presence_of :name, :email 49 | scope :by_email, ->(val) { where(email: val) } 50 | 51 | DEFAULT_ATTRS = { 52 | name: 'John', 53 | email: 'john@example.domain', 54 | }.freeze 55 | extend BuildDefault 56 | end 57 | 58 | class Project < ApplicationRecord 59 | belongs_to :user, required: true 60 | validates_presence_of :name 61 | extend RailsStuff::TypesTracker 62 | 63 | DEFAULT_ATTRS = { 64 | name: 'Haps.me', 65 | type: 'Project::External', 66 | }.freeze 67 | extend BuildDefault 68 | 69 | class Internal < self 70 | end 71 | 72 | class External < self 73 | end 74 | 75 | class Hidden < self 76 | unregister_type 77 | end 78 | end 79 | 80 | class Customer < ApplicationRecord 81 | extend ActiveModel::Translation 82 | extend RailsStuff::Statusable 83 | has_status_field :status, %i[verified banned premium] 84 | end 85 | 86 | class Order < ApplicationRecord 87 | extend ActiveModel::Translation 88 | extend RailsStuff::Statusable 89 | has_status_field :status, mapping: {pending: 1, accepted: 2, delivered: 3} 90 | end 91 | 92 | # # Controllers 93 | class ApplicationController < ActionController::Base 94 | extend RailsStuff::ResourcesController 95 | include Rails.application.routes.url_helpers 96 | self.view_paths = GEM_ROOT.join('spec/support/app/views') 97 | end 98 | 99 | class SiteController < ApplicationController 100 | rescue_from ActiveRecord::RecordNotFound, with: -> { head :not_found } 101 | respond_to :html 102 | end 103 | 104 | module Site 105 | class UsersController < SiteController 106 | resources_controller kaminari: true 107 | permit_attrs :name, :email 108 | has_scope :by_email 109 | end 110 | 111 | class ProjectsController < SiteController 112 | resources_controller sti: true, 113 | kaminari: true, 114 | belongs_to: [:user, optional: true] 115 | permit_attrs :name 116 | permit_attrs_for Project::External, :company 117 | permit_attrs_for Project::Internal, :department 118 | 119 | def create 120 | super(action: :index) 121 | end 122 | end 123 | 124 | class FormsController < SiteController 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | require 'action_view' 2 | require 'action_dispatch' 3 | require 'action_controller' 4 | require 'rspec/rails' 5 | require 'support/controller' 6 | -------------------------------------------------------------------------------- /spec/rails_stuff/association_writer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'integration_helper' 2 | 3 | RSpec.describe RailsStuff::AssociationWriter, :db_cleaner do 4 | let(:klass) do 5 | described_class = self.described_class 6 | build_named_class(:Project, ActiveRecord::Base) do 7 | belongs_to :user 8 | extend described_class 9 | attr_reader :username 10 | 11 | association_writer :user do |val| 12 | super(val).tap { @username = user.try!(:name) } 13 | end 14 | end 15 | end 16 | let(:instance) { klass.new } 17 | 18 | describe 'updating by id' do 19 | subject { -> { instance.user_id = new_val } } 20 | let(:new_val) { user.id } 21 | let(:user) { User.create_default! } 22 | it { should change(instance, :username).to(user.name) } 23 | it { should change(instance, :user).to(user) } 24 | its(:call) { should eq new_val } 25 | 26 | context 'when invalid id is given' do 27 | let(:new_val) { -1 } 28 | it { should_not change(instance, :username).from(nil) } 29 | it { should_not change(instance, :user).from(nil) } 30 | its(:call) { should eq new_val } 31 | end 32 | end 33 | 34 | describe 'updating with object' do 35 | subject { -> { instance.user = new_val } } 36 | let(:new_val) { user } 37 | let(:user) { User.create_default! } 38 | it { should change(instance, :username).to(user.name) } 39 | it { should change(instance, :user).to(user) } 40 | its(:call) { should eq new_val } 41 | 42 | context 'when nil is given' do 43 | let(:new_val) {} 44 | it { should_not change(instance, :username).from(nil) } 45 | it { should_not change(instance, :user).from(nil) } 46 | its(:call) { should eq new_val } 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/rails_stuff/generators/concern_generator_spec.rb: -------------------------------------------------------------------------------- 1 | # Here is Minitest test from Rails, I've made it when proposed this generator 2 | # to rails. 3 | # 4 | # While there is no built-in support for generator specs in RSpec, 5 | # it just create exaples which run Minitest tests. 6 | 7 | require 'rails_stuff/generators/concern/concern_generator' 8 | 9 | class ConcernGeneratorTest < Rails::Generators::TestCase 10 | self.generator_class = RailsStuff::Generators::ConcernGenerator 11 | arguments %w[User::Authentication] 12 | 13 | def test_concern_is_created 14 | run_generator 15 | assert_file 'app/models/user/authentication.rb' do |content| 16 | assert_match(/module User::Authentication/, content) 17 | assert_match(/extend ActiveSupport::Concern/, content) 18 | assert_match(/included do/, content) 19 | end 20 | end 21 | 22 | def test_concern_on_revoke 23 | concern_path = 'app/models/user/authentication.rb' 24 | run_generator 25 | assert_file concern_path 26 | run_generator ['User::Authentication'], behavior: :revoke 27 | assert_no_file concern_path 28 | end 29 | end 30 | 31 | class ControllerConcernGeneratorTest < Rails::Generators::TestCase 32 | self.generator_class = RailsStuff::Generators::ConcernGenerator 33 | arguments %w[admin_controller/localized] 34 | 35 | def test_concern_is_created 36 | run_generator 37 | assert_file 'app/controllers/admin_controller/localized.rb' do |content| 38 | assert_match(/module AdminController::Localized/, content) 39 | assert_match(/extend ActiveSupport::Concern/, content) 40 | assert_match(/included do/, content) 41 | end 42 | end 43 | end 44 | 45 | Rails::Generators::TestCase.destination File.expand_path('tmp', GEM_ROOT) 46 | 47 | RSpec.describe RailsStuff::Generators::ConcernGenerator do 48 | [ConcernGeneratorTest, ControllerConcernGeneratorTest].each do |klass| 49 | klass.test_order = :random 50 | klass.runnable_methods.each do |method_name| 51 | it "[delegation to #{klass.name}##{method_name}]" do 52 | test = klass.new(method_name) 53 | test.run 54 | test.failures.first.try! { |x| raise x } 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/rails_stuff/helpers/bootstrap_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe RailsStuff::Helpers::Bootstrap, type: :helper do 2 | describe '#basic_link_icons' do 3 | context 'when used with Helper::Links' do 4 | let(:helper) do 5 | Object.tap { |x| x.extend RailsStuff::Helpers::Links }. 6 | tap { |x| x.extend described_class } 7 | end 8 | 9 | it 'returns glyphicons' do 10 | expect(helper.basic_link_icon(:edit)).to include('glyphicon') 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/rails_stuff/helpers/forms_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe RailsStuff::Helpers::Forms, type: :helper do 4 | describe '#hidden_params_fields' do 5 | before do 6 | { 7 | str_1: 'val_1', 8 | str_2: '', 9 | ar_1: %w[val_1_1 val_1_2], 10 | ar_2: %w[val_2], 11 | ar_3: [], 12 | ar_4: [''], 13 | null: nil, 14 | }.each { |k, v| params[k] = v } 15 | end 16 | 17 | def assert_inputs(fields, expected) 18 | html = helper.hidden_params_fields(*fields) 19 | result = Nokogiri::HTML.fragment(html).children. 20 | map { |x| [x.attr(:name), x.attr(:value)] } 21 | expect(result).to contain_exactly(*expected) 22 | end 23 | 24 | it 'works for scalar values' do 25 | assert_inputs %i[str_1 str_2], [%w[str_1 val_1], ['str_2', '']] 26 | assert_inputs %i[str_1 str_3], [%w[str_1 val_1]] 27 | assert_inputs %i[str_1 null], [%w[str_1 val_1], ['null', nil]] 28 | end 29 | 30 | it 'works for array' do 31 | assert_inputs %i[ar_1 ar_2], 32 | [%w(ar_1[] val_1_1), %w(ar_1[] val_1_2), %w(ar_2[] val_2)] 33 | assert_inputs %i[ar_2 ar_3], [%w(ar_2[] val_2)] 34 | assert_inputs [:ar_4], [['ar_4[]', '']] 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/rails_stuff/helpers/links_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe RailsStuff::Helpers::Links, type: :helper do 4 | before { allow(helper).to receive(:basic_link_icons) { icons } } 5 | let(:icons) { %i[destroy edit new].map { |x| [x, :"#{x}_icon"] }.to_h } 6 | 7 | describe '#basic_link_icon' do 8 | let(:icons) do 9 | { 10 | proc: -> { custom_method }, 11 | str: 'string', 12 | } 13 | end 14 | 15 | before do 16 | def helper.custom_method 17 | :custom_result 18 | end 19 | end 20 | 21 | it 'returns string and nil as is' do 22 | expect(helper.basic_link_icon(:str)).to eq 'string' 23 | expect(helper.basic_link_icon(:string)).to eq nil 24 | end 25 | 26 | it 'executes procs' do 27 | expect(helper.basic_link_icon(:proc)).to eq :custom_result 28 | end 29 | end 30 | 31 | describe '#link_to_destroy' do 32 | before { helper.extend RailsStuff::Helpers::Translation } 33 | 34 | it 'calls link_to with specific params' do 35 | expect(helper).to receive(:translate_action).with(:delete) { :delete_title } 36 | expect(helper).to receive(:translate_confirmation).with(:delete) { :confirm_delete } 37 | expect(helper).to receive(:link_to). 38 | with(:destroy_icon, :destroy_url, 39 | title: :delete_title, 40 | method: :delete, 41 | data: {confirm: :confirm_delete}, 42 | extra: :arg, 43 | ) { 'destroy_link_html' } 44 | expect(helper.link_to_destroy :destroy_url, extra: :arg).to eq 'destroy_link_html' 45 | end 46 | end 47 | 48 | describe '#link_to_edit' do 49 | before { helper.extend RailsStuff::Helpers::Translation } 50 | 51 | it 'calls link_to with specific params' do 52 | expect(helper).to receive(:translate_action).with(:edit) { :edit_title } 53 | expect(helper).to receive(:link_to). 54 | with(:edit_icon, :edit_url, title: :edit_title, extra: :arg) { 'edit_link_html' } 55 | expect(helper.link_to_edit :edit_url, extra: :arg).to eq 'edit_link_html' 56 | end 57 | end 58 | 59 | describe '#link_to_new' do 60 | it 'calls link_to with specific params' do 61 | expect(helper).to receive(:link_to). 62 | with(:new_icon, :new_url, extra: :arg) { 'new_link_html' } 63 | expect(helper.link_to_new :new_url, extra: :arg).to eq 'new_link_html' 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/rails_stuff/helpers/resource_form_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe RailsStuff::Helpers::ResourceForm do 2 | let(:helper) { Module.new.tap { |x| x.extend described_class } } 3 | 4 | describe '#resource_form_for' do 5 | it 'compiles method without errors' do 6 | expect { helper.resource_form_for }. 7 | to change { helper.instance_methods.include?(:resource_form) }.to(true) 8 | end 9 | 10 | it 'compiles method with specified name' do 11 | expect { helper.resource_form_for method: :fform }. 12 | to change { helper.instance_methods.include?(:fform) }.to(true) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/rails_stuff/helpers/text_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe RailsStuff::Helpers::Text, type: :helper do 4 | describe '#replace_blank' do 5 | before { expect(I18n).to receive(:t) { :blank } } 6 | let(:placeholder) { '(blank)' } 7 | 8 | it 'replaces blank values with translated placeholder' do 9 | expect(helper.replace_blank('')).to eq placeholder 10 | expect(helper.replace_blank(nil)).to eq placeholder 11 | 12 | expect(helper.replace_blank('0')).to eq '0' 13 | expect(helper.replace_blank(0)).to eq 0 14 | end 15 | 16 | context 'when block is given' do 17 | it 'tests value & returns block`s result if value is present' do 18 | expect(helper.replace_blank('') { 'block' }).to eq placeholder 19 | expect(helper.replace_blank([]) { 'block' }).to eq placeholder 20 | expect(helper.replace_blank(nil) { 'block' }).to eq placeholder 21 | expect(helper.replace_blank(nil, &:name)).to eq placeholder 22 | 23 | expect(helper.replace_blank('test') { 'block' }).to eq 'block' 24 | expect(helper.replace_blank(1) { 'block' }).to eq 'block' 25 | expect(helper.replace_blank([1]) { 'block' }).to eq 'block' 26 | 27 | expect(helper.replace_blank(2) { |x| 'block' * x }).to eq 'blockblock' 28 | expect(helper.replace_blank(double(name: 'test'), &:name)).to eq 'test' 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/rails_stuff/helpers/translation_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe RailsStuff::Helpers::Translation do 2 | let(:helper) { Object.new.tap { |x| x.extend described_class } } 3 | 4 | before do 5 | I18n.backend = I18n::Backend::Simple.new 6 | I18n.backend.store_translations 'en', helpers: { 7 | actions: { 8 | edit: 'edit_en', 9 | delete: 'delete_en', 10 | }, 11 | confirmations: { 12 | destroy: 'destroy_en', 13 | clean: 'clean_en', 14 | }, 15 | yes_no: { 16 | 'true' => 'yeah', 17 | 'false' => 'nope', 18 | }, 19 | } 20 | end 21 | 22 | def self.with_i18n_raise(val) 23 | around do |ex| 24 | begin 25 | old_val = described_class.i18n_raise 26 | described_class.i18n_raise = val 27 | ex.run 28 | ensure 29 | described_class.i18n_raise = old_val 30 | end 31 | end 32 | end 33 | 34 | shared_examples 'raising on missing translation' do 35 | context 'when translation is missing' do 36 | let(:input) { 'missing_key' } 37 | 38 | context 'when .i18n_raise is true' do 39 | with_i18n_raise(false) 40 | its(:call) { should be_present } 41 | end 42 | 43 | context 'when .i18n_raise is true' do 44 | with_i18n_raise(true) 45 | it { should raise_error(I18n::MissingTranslationData) } 46 | end 47 | end 48 | end 49 | 50 | describe '#translate_action' do 51 | subject { ->(val = input) { helper.translate_action(val) } } 52 | include_examples 'raising on missing translation' 53 | 54 | it 'translates and caches actions' do 55 | expect(I18n).to receive(:t).once.and_call_original 56 | 2.times { expect(subject[:edit]).to eq 'edit_en' } 57 | expect(I18n).to receive(:t).once.and_call_original 58 | 2.times { expect(subject['delete']).to eq 'delete_en' } 59 | end 60 | end 61 | 62 | describe '#translate_confirmation' do 63 | subject { ->(val = input) { helper.translate_confirmation(val) } } 64 | include_examples 'raising on missing translation' 65 | 66 | it 'translates and caches confirmations' do 67 | expect(I18n).to receive(:t).once.and_call_original 68 | 2.times { expect(subject[:destroy]).to eq 'destroy_en' } 69 | expect(I18n).to receive(:t).once.and_call_original 70 | 2.times { expect(subject['clean']).to eq 'clean_en' } 71 | end 72 | end 73 | 74 | describe '#yes_no' do 75 | subject { ->(val = input) { helper.yes_no(val) } } 76 | include_examples 'raising on missing translation' 77 | 78 | it 'translates and caches boolean values' do 79 | expect(I18n).to receive(:t).once.and_call_original 80 | 2.times { expect(subject[true]).to eq 'yeah' } 81 | expect(I18n).to receive(:t).once.and_call_original 82 | 2.times { expect(subject[false]).to eq 'nope' } 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/rails_stuff/nullify_blank_attrs_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe RailsStuff::NullifyBlankAttrs do 2 | let(:klass) do 3 | described_class = self.described_class 4 | Class.new do 5 | extend described_class 6 | nullify_blank_attrs :nested, :own 7 | 8 | include(Module.new do 9 | attr_accessor :nested 10 | end) 11 | 12 | attr_accessor :own 13 | end 14 | end 15 | let(:instance) { klass.new } 16 | around { |ex| RailsStuff.deprecation_07.silence { ex.run } } 17 | 18 | it 'nullifies blank attrs' do 19 | expect { instance.nested = 'test' }.to change { instance.nested }.from(nil).to('test') 20 | expect { instance.nested = ' ' }.to change { instance.nested }.to(nil) 21 | end 22 | 23 | it 'works with attrs defined in class' do 24 | expect { instance.own = 'test' }.to change { instance.own }.from(nil).to('test') 25 | expect { instance.own = ' ' }.to change { instance.own }.to(nil) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/rails_stuff/params_parser_spec.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'active_support/core_ext/time' 3 | 4 | Time.zone_default = Time.find_zone('UTC') 5 | 6 | RSpec.describe RailsStuff::ParamsParser do 7 | describe '.parse_string' do 8 | subject { ->(val = input) { described_class.parse_string(val) } } 9 | 10 | it 'casts input to string' do 11 | { 12 | nil => nil, 13 | '' => '', 14 | 'a' => 'a', 15 | 1 => '1', 16 | [] => '[]', 17 | }.each do |input, expected| 18 | expect(subject.call(input)).to eq expected 19 | end 20 | end 21 | 22 | context 'when input is invalid' do 23 | let(:input) { Object.new } 24 | before do 25 | def input.to_s 26 | raise 'test' 27 | end 28 | end 29 | it { should raise_error(described_class::Error) } 30 | end 31 | end 32 | 33 | describe '.parse_string_array' do 34 | subject { ->(val = input) { described_class.parse_string_array(val) } } 35 | 36 | it 'casts input to string' do 37 | { 38 | nil => nil, 39 | '' => nil, 40 | [1, :a, 'b'] => %w[1 a b], 41 | ['str', '', nil] => ['str', '', nil], 42 | }.each do |input, expected| 43 | expect(subject.call(input)).to eq expected 44 | end 45 | end 46 | 47 | context 'when input is invalid' do 48 | let(:input) { [Object.new, '1'] } 49 | before do 50 | val = input[0] 51 | def val.to_s 52 | raise 'test' 53 | end 54 | end 55 | it { should raise_error(described_class::Error) } 56 | end 57 | end 58 | 59 | describe '.parse_int' do 60 | subject { ->(val = input) { described_class.parse_int(val) } } 61 | 62 | it 'casts input to integer' do 63 | { 64 | nil => nil, 65 | '' => nil, 66 | 'a' => 0, 67 | '1' => 1, 68 | '-5.5' => -5, 69 | }.each do |input, expected| 70 | expect(subject.call(input)).to be expected 71 | end 72 | end 73 | 74 | context 'when input is invalid' do 75 | let(:input) { [] } 76 | it { should raise_error(described_class::Error) } 77 | end 78 | end 79 | 80 | describe '.parse_int_array' do 81 | subject { ->(val = input) { described_class.parse_int_array(val) } } 82 | 83 | it 'casts input to integer' do 84 | { 85 | nil => nil, 86 | '' => nil, 87 | ['1', '2', 3, '-5.5'] => [1, 2, 3, -5], 88 | ['1', '2', '', '3', 'a', nil] => [1, 2, nil, 3, 0, nil], 89 | }.each do |input, expected| 90 | expect(subject.call(input)).to eq expected 91 | end 92 | end 93 | 94 | context 'when input is invalid' do 95 | let(:input) { [['1'], '1'] } 96 | it { should raise_error(described_class::Error) } 97 | end 98 | end 99 | 100 | describe '.parse_decimal' do 101 | subject { ->(val = input) { described_class.parse_decimal(val) } } 102 | 103 | it 'casts input to BigDecimal' do 104 | { 105 | nil => nil, 106 | '' => nil, 107 | 'a' => 0, 108 | '1' => 1, 109 | '-5.5' => -5.5, 110 | }.each do |input, expected| 111 | result = subject.call(input) 112 | expect(result).to eq expected 113 | expect(result).to be_instance_of BigDecimal if result 114 | end 115 | end 116 | 117 | context 'when input is invalid' do 118 | let(:input) { [] } 119 | it { should raise_error(described_class::Error) } 120 | end 121 | end 122 | 123 | describe '.parse_decimal_array' do 124 | subject { ->(val = input) { described_class.parse_decimal_array(val) } } 125 | 126 | it 'casts input to BigDecimal' do 127 | { 128 | nil => nil, 129 | '' => nil, 130 | ['1', '2', 3, '-5.5'] => [1, 2, 3, -5.5], 131 | ['1', '2', '', '3', 'a', nil] => [1, 2, nil, 3, 0, nil], 132 | }.each do |input, expected| 133 | result = subject.call(input) 134 | expect(result).to eq expected 135 | result.each { |x| expect(x).to be_instance_of BigDecimal if x } if result 136 | end 137 | end 138 | 139 | context 'when input is invalid' do 140 | let(:input) { [['1'], '1'] } 141 | it { should raise_error(described_class::Error) } 142 | end 143 | end 144 | 145 | describe '.parse_boolean' do 146 | subject { ->(val = input) { described_class.parse_boolean(val) } } 147 | it 'parses boolean values' do 148 | [nil, ''].each { |x| expect(subject.call(x)).to eq nil } 149 | [true, '1', 't', 'true', 'on'].each { |x| expect(subject.call(x)).to eq true } 150 | [false, '0', 'f', 'false', 'off'].each { |x| expect(subject.call(x)).to eq false } 151 | end 152 | end 153 | 154 | describe '.parse_datetime' do 155 | subject { ->(val = input) { described_class.parse_datetime(val) } } 156 | 157 | context 'when argument is a string' do 158 | it 'parses time' do 159 | expect(subject.call('19:00')).to eq(Time.zone.parse('19:00')) 160 | expect(subject.call('01.02.2013 19:00')).to eq(Time.zone.parse('2013-02-01 19:00')) 161 | end 162 | 163 | context 'when argument is empty string' do 164 | let(:input) { '' } 165 | its(:call) { should eq nil } 166 | end 167 | 168 | context 'when argument is not date representation' do 169 | let(:input) { 'smth' } 170 | it { should raise_error(described_class::Error) } 171 | end 172 | end 173 | 174 | context 'when argument is nil' do 175 | let(:input) {} 176 | its(:call) { should be nil } 177 | end 178 | 179 | context 'when argument is not a string' do 180 | it 'raise error' do 181 | expect { subject.call([]) }.to raise_error(described_class::Error) 182 | expect { subject.call({}) }.to raise_error(described_class::Error) 183 | end 184 | end 185 | end 186 | 187 | describe '.parse_json' do 188 | subject { ->(val = input) { described_class.parse_json(val) } } 189 | 190 | context 'when argument is nil' do 191 | let(:input) {} 192 | its(:call) { should be nil } 193 | end 194 | 195 | context 'when argument is a string' do 196 | it 'parses json' do 197 | expect(subject.call('{"a": 1}')).to eq('a' => 1) 198 | expect(subject.call('[1,2]')).to eq([1, 2]) 199 | end 200 | 201 | context 'when argument is empty string' do 202 | let(:input) { '' } 203 | its(:call) { should be nil } 204 | end 205 | 206 | context 'when argument is invalid json' do 207 | it 'raise error' do 208 | expect { subject.call('{') }.to raise_error(described_class::Error) 209 | expect { subject.call('[]]') }.to raise_error(described_class::Error) 210 | end 211 | end 212 | end 213 | 214 | context 'when argument is not a string' do 215 | it 'raise error' do 216 | expect { subject.call([1]) }.to raise_error(described_class::Error) 217 | expect { subject.call(a: 1) }.to raise_error(described_class::Error) 218 | end 219 | end 220 | end 221 | end 222 | -------------------------------------------------------------------------------- /spec/rails_stuff/random_uniq_attr_spec.rb: -------------------------------------------------------------------------------- 1 | require 'support/active_record' 2 | 3 | RSpec.describe RailsStuff::RandomUniqAttr, :db_cleaner do 4 | let(:model) do 5 | described_class = self.described_class 6 | attr = self.attr 7 | Class.new(ApplicationRecord) do 8 | self.table_name = :tokens 9 | extend described_class 10 | random_uniq_attr attr 11 | end 12 | end 13 | let(:attr) { :code } 14 | 15 | def create_instance(attrs = {}) 16 | model.create!(attrs) 17 | end 18 | 19 | describe '#set_#{attr}' do 20 | let(:other_instance) { create_instance attr => :test } 21 | let(:instance) { create_instance } 22 | 23 | context 'when instance with same generated attr value exists' do 24 | let(:values) { ([:test_2] + [other_instance[attr]] * 5).map(&:to_s) } 25 | before do 26 | other_instance 27 | allow(other_instance.class).to receive("generate_#{attr}") { values.pop }.times 28 | end 29 | 30 | it 'generates new value until it is unique' do 31 | expect(instance[attr]).to eq('test_2') 32 | expect(instance.reload[attr]).to eq('test_2') 33 | end 34 | 35 | context 'when uniq value can not be generated' do 36 | let(:values) { double(:values, pop: other_instance[attr]) } 37 | it { expect { instance }.to raise_error ActiveRecord::RecordNotUnique } 38 | end 39 | end 40 | 41 | it 'passes instance to generate_ method' do 42 | expect(instance.class).to receive("generate_#{attr}").with(instance) { 'new_val' } 43 | expect { instance.send "set_#{attr}" }.to change { instance[attr] }.to 'new_val' 44 | end 45 | end 46 | 47 | describe '.random_uniq_attr' do 48 | let(:klass) do 49 | described_class = self.described_class 50 | Class.new(ApplicationRecord) { extend described_class } 51 | end 52 | 53 | context 'when block is given' do 54 | let(:values) { 5.times.to_a } 55 | before do 56 | values = self.values 57 | klass.random_uniq_attr(:key) { values.shift } 58 | end 59 | 60 | it 'uses block as generator' do 61 | values.dup.each do |val| 62 | expect(klass.generate_key).to eq(val) 63 | end 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/rails_stuff/redis_storage_spec.rb: -------------------------------------------------------------------------------- 1 | require 'support/redis' 2 | 3 | RSpec.describe RailsStuff::RedisStorage do 4 | let(:model) do 5 | described_class = self.described_class 6 | Class.new do 7 | extend described_class 8 | self.redis_prefix = :rails_stuff_test 9 | end 10 | end 11 | let(:prefix) { model.redis_prefix } 12 | 13 | let(:parent) do 14 | described_class = self.described_class 15 | Class.new { extend described_class } 16 | end 17 | let(:child) { Class.new(parent) } 18 | let(:redis) { parent.redis_pool.simple_connection } 19 | 20 | describe '#redis_set_options' do 21 | it 'inherits value from parent' do 22 | parent_val = {a: 1} 23 | child_val = {b: 2} 24 | 25 | expect(parent.redis_set_options).to eq({}) 26 | expect(child.redis_set_options).to eq({}) 27 | 28 | parent.redis_set_options = parent_val 29 | expect(parent.redis_set_options).to eq(parent_val) 30 | expect(child.redis_set_options).to eq(parent_val) 31 | 32 | child.redis_set_options = child_val 33 | expect(parent.redis_set_options).to eq(parent_val) 34 | expect(child.redis_set_options).to eq(child_val) 35 | end 36 | end 37 | 38 | describe '#redis_key_for' do 39 | it 'works with scalars' do 40 | expect(model.redis_key_for(1)).to eq "#{prefix}:1" 41 | expect(model.redis_key_for(nil)).to eq "#{prefix}:" 42 | expect(model.redis_key_for('test')).to eq "#{prefix}:test" 43 | model.redis_prefix = :test_2 44 | expect(model.redis_key_for(1)).to eq 'test_2:1' 45 | end 46 | 47 | it 'works with array' do 48 | expect(model.redis_key_for([])).to eq "#{prefix}:" 49 | expect(model.redis_key_for([1, 2])).to eq "#{prefix}:1:2" 50 | expect(model.redis_key_for([1, nil, 2])).to eq "#{prefix}:1::2" 51 | expect(model.redis_key_for(['test', :a])).to eq "#{prefix}:test:a" 52 | model.redis_prefix = :test_2 53 | expect(model.redis_key_for([1, 2])).to eq 'test_2:1:2' 54 | end 55 | end 56 | 57 | describe '#redis_id_seq_key' do 58 | it 'works with scalars' do 59 | expect(model.redis_id_seq_key).to eq "#{prefix}_id_seq" 60 | expect(model.redis_id_seq_key(nil)).to eq "#{prefix}_id_seq" 61 | expect(model.redis_id_seq_key('test')).to eq "#{prefix}_id_seq:test" 62 | model.redis_prefix = :test_2 63 | expect(model.redis_id_seq_key(1)).to eq 'test_2_id_seq:1' 64 | end 65 | 66 | it 'works with array' do 67 | expect(model.redis_id_seq_key([])).to eq "#{prefix}_id_seq" 68 | expect(model.redis_id_seq_key([1, 2])).to eq "#{prefix}_id_seq:1:2" 69 | expect(model.redis_id_seq_key([1, nil, 2])).to eq "#{prefix}_id_seq:1::2" 70 | expect(model.redis_id_seq_key(['test', :a])).to eq "#{prefix}_id_seq:test:a" 71 | model.redis_prefix = :test_2 72 | expect(model.redis_id_seq_key([1, 2])).to eq 'test_2_id_seq:1:2' 73 | end 74 | end 75 | 76 | describe '#reset_id_seq' do 77 | it 'deletes counter' do 78 | redis.set("#{prefix}_id_seq", 1) 79 | redis.set("#{prefix}_id_seq:1:2", 1) 80 | 81 | expect { model.reset_id_seq }. 82 | to change { redis.get("#{prefix}_id_seq") }.to(nil) 83 | expect { model.reset_id_seq([1, 2]) }. 84 | to change { redis.get("#{prefix}_id_seq:1:2") }.to(nil) 85 | end 86 | end 87 | 88 | describe '#next_id' do 89 | it 'returns sequental numbers' do 90 | redis.del("#{prefix}_id_seq") 91 | redis.del("#{prefix}_id_seq:1:2") 92 | 93 | { 94 | 1 => nil, 95 | 2 => 1, 96 | }.each do |next_val, prev_val| 97 | expect { expect(model.next_id).to eq(next_val) }. 98 | to change { redis.get("#{prefix}_id_seq").try(:to_i) }. 99 | from(prev_val).to(next_val) 100 | 101 | expect { expect(model.next_id([1, 2])).to eq(next_val) }. 102 | to change { redis.get("#{prefix}_id_seq:1:2").try(:to_i) }. 103 | from(prev_val).to(next_val) 104 | end 105 | end 106 | end 107 | 108 | describe '#set' do 109 | context 'when id is nil' do 110 | it 'generates new id for the record' do 111 | model.reset_id_seq 112 | redis.del("#{prefix}:1") 113 | redis.del("#{prefix}:2") 114 | 115 | expect do 116 | expect { expect(model.set(nil, :test)).to eq(1) }. 117 | to change { redis.get("#{prefix}_id_seq").try(:to_i) }.from(nil).to(1) 118 | end.to change { redis.get("#{prefix}:1") }.from(nil) 119 | expect do 120 | expect { expect(model.set([], :test)).to eq(2) }. 121 | to change { redis.get("#{prefix}_id_seq").try(:to_i) }.from(1).to(2) 122 | end.to change { redis.get("#{prefix}:2") }.from(nil) 123 | end 124 | 125 | context 'for scoped key' do 126 | it 'generates new id for the record' do 127 | model.reset_id_seq(%i[a b]) 128 | redis.del("#{prefix}:a:b:1") 129 | 130 | expect do 131 | expect { expect(model.set([:a, :b, nil], :test)).to eq(1) }. 132 | to change { redis.get("#{prefix}_id_seq:a:b").try(:to_i) }.from(nil).to(1) 133 | end.to change { redis.get("#{prefix}:a:b:1") }.from(nil) 134 | end 135 | end 136 | end 137 | 138 | context 'when id is not nil' do 139 | it 'updates value' do 140 | redis.set("#{prefix}:1", 1) 141 | redis.set("#{prefix}:a:b:1", 1) 142 | 143 | expect { model.set(1, 2) }.to change { redis.get("#{prefix}:1") }.from('1') 144 | expect { model.set([:a, :b, 1], 2) }. 145 | to change { redis.get("#{prefix}:a:b:1") }.from('1') 146 | end 147 | end 148 | end 149 | 150 | describe '#delete' do 151 | it 'removes key' do 152 | redis.set("#{prefix}:1", 1) 153 | redis.set("#{prefix}:a:b:1", 1) 154 | 155 | expect { model.delete(1) }. 156 | to change { redis.get("#{prefix}:1") }.from('1').to(nil) 157 | expect { model.delete([:a, :b, 1]) }. 158 | to change { redis.get("#{prefix}:a:b:1") }.from('1').to(nil) 159 | end 160 | end 161 | 162 | describe '#get' do 163 | it 'returns saved value' do 164 | model.set(1, test: 1) 165 | expect(model.get(1)).to eq(test: 1) 166 | model.set([:a, :b, 2], test: 2) 167 | expect(model.get([:a, :b, 2])).to eq(test: 2) 168 | end 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /spec/rails_stuff/resources_controller/actions_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe RailsStuff::ResourcesController::Actions do 2 | let(:klass) { build_controller_class } 3 | let(:controller) { klass.new } 4 | let(:resource) { double(:resource) } 5 | let(:block) { -> {} } 6 | let(:options) { {option: :param} } 7 | before do 8 | allow(controller).to receive_messages( 9 | after_save_url: :save_redirect_url, 10 | after_destroy_url: :destroy_redirect_url, 11 | resource: resource, 12 | ) 13 | end 14 | 15 | def build_controller_class(class_name = nil) 16 | described_class = self.described_class 17 | build_named_class(class_name, ActionController::Base) do 18 | include RailsStuff::ResourcesController::BasicHelpers 19 | include described_class 20 | end 21 | end 22 | 23 | def expect_respond_with(*args, &block) 24 | expect(controller).to receive(:respond_with).with(*args) do |&blk| 25 | expect(blk).to be block 26 | end 27 | end 28 | 29 | shared_examples 'with custom location' do 30 | context 'and custom location is given' do 31 | let(:options) { super().merge(location: :my_custom_location) } 32 | it 'doesnt override location' do 33 | expect_respond_with(resource, options, &block) 34 | subject.call 35 | end 36 | end 37 | end 38 | 39 | describe '#new' do 40 | it 'calls build_resource' do 41 | expect(controller).to receive(:build_resource) 42 | controller.new 43 | end 44 | end 45 | 46 | describe '#create' do 47 | subject { -> { controller.create options.dup, &block } } 48 | before { expect(controller).to receive_messages(create_resource: create_result) } 49 | 50 | context 'when create succeeded' do 51 | let(:create_result) { true } 52 | it 'sets location and calls respond_with' do 53 | expect_respond_with(resource, **options, location: :save_redirect_url, &block) 54 | subject.call 55 | end 56 | 57 | include_examples 'with custom location' 58 | end 59 | 60 | context 'when create failed' do 61 | let(:create_result) { false } 62 | it 'calls respond_with, but doesnt set location' do 63 | expect_respond_with(resource, options, &block) 64 | subject.call 65 | end 66 | end 67 | end 68 | 69 | describe '#update' do 70 | subject { -> { controller.update options.dup, &block } } 71 | before { expect(controller).to receive_messages(update_resource: update_result) } 72 | 73 | context 'when update succeeded' do 74 | let(:update_result) { true } 75 | it 'sets location and calls respond_with' do 76 | expect_respond_with(resource, **options, location: :save_redirect_url, &block) 77 | subject.call 78 | end 79 | 80 | include_examples 'with custom location' 81 | end 82 | 83 | context 'when update failed' do 84 | let(:update_result) { false } 85 | it 'calls respond_with, but doesnt set location' do 86 | expect_respond_with(resource, options, &block) 87 | subject.call 88 | end 89 | end 90 | end 91 | 92 | describe '#destroy' do 93 | subject { -> { controller.destroy options.dup, &block } } 94 | before do 95 | expect(resource).to receive(:destroy) 96 | expect(controller).to receive_messages(flash_errors!: true) 97 | end 98 | 99 | it 'calls rsource.destroy, flash_errors! and respond_with' do 100 | expect_respond_with(resource, option: :param, location: :destroy_redirect_url, &block) 101 | subject.call 102 | end 103 | 104 | include_examples 'with custom location' 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /spec/rails_stuff/resources_controller/basic_helpers_spec.rb: -------------------------------------------------------------------------------- 1 | require 'active_model' 2 | 3 | RSpec.describe RailsStuff::ResourcesController::BasicHelpers do 4 | let(:klass) { build_controller_class } 5 | let(:controller) { klass.new } 6 | 7 | def build_controller_class(class_name = nil) 8 | described_class = self.described_class 9 | build_named_class(class_name, ActionController::Base) do 10 | include described_class 11 | end 12 | end 13 | 14 | describe '.resource_class' do 15 | subject { ->(klass = self.klass) { klass.resource_class } } 16 | 17 | it 'detects class name from controller`s name' do 18 | stub_const('User', Class.new) 19 | stub_const('Product', Class.new) 20 | expect(subject.call build_controller_class 'UsersController').to be User 21 | expect(subject.call build_controller_class 'UserController').to be User 22 | expect(subject.call build_controller_class 'Admin::UserController').to be User 23 | expect(subject.call build_controller_class 'Admin::ProductsController').to be Product 24 | end 25 | 26 | context 'when class can not be automatically determined' do 27 | let(:klass) { build_controller_class :ProductsController } 28 | it { should raise_error(NameError, /uninitialized constant/) } 29 | 30 | context 'when class is set explicitly' do 31 | before { klass.resource_class = :custom_model } 32 | its(:call) { should eq :custom_model } 33 | end 34 | end 35 | 36 | context 'for anonymous class' do 37 | it { should raise_error(NameError, /wrong constant name/) } 38 | end 39 | end 40 | 41 | describe '.resource_param_name' do 42 | subject { ->(klass = self.klass) { klass.resource_param_name } } 43 | 44 | it 'takes value from class`es param_key' do 45 | stub_const('User', build_named_class(:Admin) { include ActiveModel::Model }) 46 | expect(subject.call build_controller_class 'UsersController').to eq 'admin' 47 | end 48 | 49 | context 'when resource_class is set explicitly' do 50 | let(:model) { build_named_class(:Product) { include ActiveModel::Model } } 51 | let(:klass) { super().tap { |x| x.resource_class = model } } 52 | its(:call) { should eq 'product' } 53 | end 54 | 55 | context 'when value is set explicitly' do 56 | before { klass.resource_param_name = :custom_id } 57 | its(:call) { should eq :custom_id } 58 | end 59 | end 60 | 61 | describe '.permit_attrs' do 62 | let(:other_class) { build_controller_class } 63 | around { |ex| expect { ex.run }.to_not change(other_class, :permitted_attrs) } 64 | 65 | it 'adds args to .permitted_attrs' do 66 | expect { klass.permit_attrs :name, :lastname }. 67 | to change(klass, :permitted_attrs).from([]).to(%i[name lastname]) 68 | expect { klass.permit_attrs project_attributes: %i[id _destroy] }. 69 | to change(klass, :permitted_attrs).by([project_attributes: %i[id _destroy]]) 70 | end 71 | end 72 | 73 | describe '#permitted_attrs' do 74 | it 'returns class-level permitted_attrs by default' do 75 | expect { klass.permit_attrs :name }. 76 | to change(controller, :permitted_attrs).from([]).to([:name]) 77 | end 78 | end 79 | 80 | describe '#resource_params' do 81 | subject { -> { klass.new.send(:resource_params) } } 82 | # In Rails4 #to_h returns ::Hash. 83 | let(:subject_to_h) { -> { subject.call.to_h.with_indifferent_access } } 84 | let(:params) do 85 | { 86 | user: {name: 'Name', lastname: 'Lastname', admin: true, id: 1}, 87 | project: {title: 'Title', _destroy: true, id: 2}, 88 | } 89 | end 90 | let(:params_class) { ActionController::Parameters } 91 | 92 | before do 93 | klass.resource_param_name = :user 94 | allow_any_instance_of(klass).to receive(:params) { params_class.new params.as_json } 95 | end 96 | 97 | it 'uses #permitted_attrs and .resource_param_name to filter params' do 98 | expect { klass.permit_attrs :name, :lastname, :address }. 99 | to change(&subject_to_h).from({}).to(name: 'Name', lastname: 'Lastname') 100 | expect(subject.call).to be_instance_of(params_class) 101 | expect { klass.resource_param_name = :project }. 102 | to change(&subject_to_h).to({}) 103 | expect { klass.permit_attrs :title }. 104 | to change(&subject_to_h).to(title: 'Title') 105 | expect { klass.resource_param_name = :missing_key }. 106 | to change(&subject_to_h).to({}) 107 | end 108 | 109 | context 'when resource key is not present in params' do 110 | before { klass.resource_param_name = :company } 111 | its(:call) { should be_instance_of(params_class) } 112 | its(:call) { should eq(params_class.new.permit!) } 113 | its('call.permitted?') { should eq true } 114 | end 115 | end 116 | 117 | describe '#after_save_url' do 118 | it 'passes .after_save_action to url_for, and uses resource as id (except :index)' do 119 | allow(controller).to receive(:resource) { :resource_obj } 120 | expect(controller).to receive(:url_for). 121 | with(action: :show, id: :resource_obj) { :url } 122 | expect(controller.send :after_save_url).to eq :url 123 | 124 | controller.class.after_save_action = :index 125 | expect(controller).to receive(:url_for).with(action: :index) { :url } 126 | expect(controller.send :after_save_url).to eq :url 127 | 128 | controller.class.after_save_action = :custom 129 | expect(controller).to receive(:url_for). 130 | with(action: :custom, id: :resource_obj) { :url } 131 | expect(controller.send :after_save_url).to eq :url 132 | end 133 | end 134 | 135 | describe '#after_destroy_url' do 136 | it 'returns url_for :index action' do 137 | expect(controller).to receive(:url_for).with(action: :index) { :url } 138 | expect(controller.send :after_destroy_url).to eq :url 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /spec/rails_stuff/resources_controller/resource_helper_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe RailsStuff::ResourcesController::ResourceHelper do 2 | let(:klass) do 3 | described_class = self.described_class 4 | Class.new(ActionController::Base) { extend described_class } 5 | end 6 | let(:params) { {user_id: 1, secret_id: 2, admin_user_id: 3} } 7 | let(:controller) do 8 | params = ActionController::Parameters.new(self.params) 9 | klass.new.tap { |x| allow(x).to receive(:params) { params } } 10 | end 11 | 12 | describe '#resource_helper' do 13 | subject { -> { klass.resource_helper(method, options) } } 14 | let(:method) { :user } 15 | let(:options) { {} } 16 | let(:model_name) { :User } 17 | let!(:model) { stub_const(model_name.to_s, double(:model)) } 18 | 19 | shared_examples 'helper methods' do 20 | it 'generates helper methods' do 21 | expected_methods = [method, :"#{method}?"] 22 | expect(klass).to receive(:helper_method).with(*expected_methods) 23 | should change { klass.protected_instance_methods.sort }.by(expected_methods) 24 | end 25 | end 26 | 27 | shared_examples 'finder method' do |param| 28 | it 'generates finder method' do 29 | subject.call 30 | expect(model).to receive(:find).with(params[param]) { :found_user } 31 | expect do 32 | expect(controller.send(method)).to eq :found_user 33 | end.to change { controller.instance_variable_get("@#{method}") }.from(nil).to(:found_user) 34 | # assert cache 35 | expect(model).to_not receive(:find) 36 | expect(controller.send(method)).to eq :found_user 37 | end 38 | end 39 | 40 | shared_examples 'enquirer method' do |param| 41 | it 'generates enquirer method' do 42 | subject.call 43 | expect(controller.params).to receive(:key?).with(param) { :key_result } 44 | expect(controller.send("#{method}?")).to eq :key_result 45 | end 46 | end 47 | 48 | include_examples 'helper methods' 49 | include_examples 'finder method', :user_id 50 | include_examples 'enquirer method', :user_id 51 | 52 | context 'when :class is given' do 53 | let(:method) { :admin_user } 54 | let(:options) { {class: :Admin} } 55 | let(:model_name) { :Admin } 56 | around { |ex| RailsStuff.deprecation_07.silence { ex.run } } 57 | include_examples 'finder method', :admin_user_id 58 | end 59 | 60 | context 'when :source is given' do 61 | let(:method) { :admin_user } 62 | let(:options) { {source: :Admin} } 63 | let(:model_name) { :Admin } 64 | include_examples 'helper methods' 65 | include_examples 'finder method', :admin_user_id 66 | include_examples 'enquirer method', :admin_user_id 67 | 68 | context 'with class' do 69 | let(:options) { {source: -> { get_source }} } 70 | let(:options) { {source: model} } 71 | let(:model) { Class.new { def self.find(*); end } } 72 | include_examples 'finder method', :admin_user_id 73 | end 74 | 75 | context 'with block' do 76 | let(:options) { {source: -> { get_source }} } 77 | let(:model) { double(:model).tap { |x| klass.send(:define_method, :get_source) { x } } } 78 | include_examples 'finder method', :admin_user_id 79 | end 80 | end 81 | 82 | context 'when :param is given' do 83 | let(:options) { {param: :secret_id} } 84 | include_examples 'helper methods' 85 | include_examples 'finder method', :secret_id 86 | include_examples 'enquirer method', :secret_id 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /spec/rails_stuff/resources_controller/sti_helpers_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe RailsStuff::ResourcesController::StiHelpers do 2 | let(:klass) do 3 | build_controller_class.tap do |x| 4 | x.resource_class = resource_class 5 | x.resource_param_name = :user 6 | end 7 | end 8 | let(:controller) { klass.new } 9 | let(:resource_class) { Class.new } 10 | 11 | def build_controller_class(class_name = nil) 12 | described_class = self.described_class 13 | build_named_class(class_name, ActionController::Base) do 14 | include RailsStuff::ResourcesController::BasicHelpers 15 | include described_class 16 | end 17 | end 18 | 19 | describe '.resource_class_by_type' do 20 | subject { klass.resource_class_by_type } 21 | let(:child_1) { build_named_class :Child1, resource_class } 22 | let(:child_2) { build_named_class :Child2, resource_class } 23 | let(:grandchild) { build_named_class :Grandchild, child_1 } 24 | 25 | it 'is populated with resource_class descendants' do 26 | should eq 'Child1' => child_1, 'Child2' => child_2, 'Grandchild' => grandchild 27 | end 28 | 29 | context 'when resource_class responds to .types_list' do 30 | let(:resource_class) { super().tap { |x| x.extend RailsStuff::TypesTracker } } 31 | before { child_1.unregister_type } 32 | 33 | it 'is populated from types_list' do 34 | should eq 'Child2' => child_2, 'Grandchild' => grandchild 35 | end 36 | end 37 | end 38 | 39 | describe '#class_from_request' do 40 | subject { -> { controller.send :class_from_request } } 41 | let(:model) { Class.new } 42 | let(:params) { {} } 43 | before do 44 | allow_any_instance_of(klass). 45 | to receive(:params) { ActionController::Parameters.new(params) } 46 | klass.resource_class_by_type['User::Admin'] = model 47 | end 48 | 49 | context 'when valid type is requested' do 50 | let(:params) { {user: {type: 'User::Admin'}} } 51 | it 'returns mapped class for this type' do 52 | expect(subject.call).to eq model 53 | end 54 | end 55 | 56 | context 'when invalid type is requested' do 57 | let(:params) { {user: {type: 'Project::External'}} } 58 | it { should raise_error described_class::InvalidType } 59 | 60 | context 'when use_resource_class_for_invalid_type is true' do 61 | before { klass.use_resource_class_for_invalid_type = true } 62 | its(:call) { should eq klass.resource_class } 63 | end 64 | end 65 | 66 | context 'when params for resource is not given' do 67 | let(:params) { {project: {type: 'Project::External'}} } 68 | its(:call) { should eq klass.resource_class } 69 | end 70 | 71 | context 'when id is present' do 72 | let(:params) { {id: 1} } 73 | 74 | it 'takes returns resource`s class' do 75 | resource = Class.new.new 76 | expect(controller).to receive(:resource) { resource } 77 | expect(subject.call).to be resource.class 78 | end 79 | end 80 | end 81 | 82 | describe '.permit_attrs_for' do 83 | let(:other_class) { build_controller_class } 84 | around { |ex| expect { ex.run }.to_not change(other_class, :permitted_attrs_for) } 85 | around do |ex| 86 | expect { ex.run }.to_not change { klass.permitted_attrs_for[:other_type] } 87 | end 88 | 89 | it 'adds args to .permitted_attrs_for' do 90 | expect { klass.permit_attrs_for :user, :name, :lastname }. 91 | to change { klass.permitted_attrs_for[:user] }. 92 | from([]).to(%i[name lastname]) 93 | expect { klass.permit_attrs_for :user, project_attributes: %i[id _destroy] }. 94 | to change { klass.permitted_attrs_for[:user] }. 95 | by([project_attributes: %i[id _destroy]]) 96 | end 97 | end 98 | 99 | describe '#permitted_attrs' do 100 | it 'concats permitted_attrs with permitted_attrs_for specific klass' do 101 | model_1 = Class.new 102 | model_2 = Class.new 103 | klass.permit_attrs :name 104 | klass.permit_attrs_for model_1, :rule 105 | klass.permit_attrs_for model_2, :value 106 | 107 | expect(controller).to receive(:class_from_request) { model_1 } 108 | expect(controller.send :permitted_attrs).to contain_exactly :name, :rule 109 | 110 | expect(controller).to receive(:class_from_request) { model_2 } 111 | expect(controller.send :permitted_attrs).to contain_exactly :name, :value 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /spec/rails_stuff/resources_controller_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe RailsStuff::ResourcesController do 2 | describe '#resources_controller' do 3 | subject { ->(options = self.options) { klass.resources_controller(**options) } } 4 | let(:options) { {} } 5 | let(:klass) do 6 | described_class = self.described_class 7 | Class.new(ActionController::Base) { extend described_class } 8 | end 9 | let(:instance) { klass.new } 10 | let(:basic_modules) do 11 | [ 12 | described_class::Actions, 13 | described_class::BasicHelpers, 14 | ] 15 | end 16 | 17 | it { should change { klass.ancestors }.by basic_modules } 18 | it { should_not change(klass, :responder) } 19 | 20 | context 'when :source_relation is given' do 21 | it 'overrides method' do 22 | subject.call source_relation: -> { :custom_source } 23 | expect(instance.send :source_relation).to eq :custom_source 24 | end 25 | end 26 | 27 | context 'when :after_save_action is given' do 28 | it 'sets it' do 29 | subject.call after_save_action: :custom_action 30 | expect(klass.after_save_action).to eq :custom_action 31 | end 32 | end 33 | 34 | context 'when :sti is true' do 35 | let(:options) { {sti: true} } 36 | let(:expected_ancestors) { basic_modules.insert(1, described_class::StiHelpers) } 37 | it { should change { klass.ancestors }.by expected_ancestors } 38 | end 39 | 40 | context 'when :kaminari is true' do 41 | let(:options) { {kaminari: true} } 42 | let(:expected_ancestors) { basic_modules.insert(1, described_class::KaminariHelpers) } 43 | it { should change { klass.ancestors }.by expected_ancestors } 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/rails_stuff/sort_scope_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | require 'support/active_record' 3 | 4 | RSpec.describe RailsStuff::SortScope do 5 | describe '.filter_param' do 6 | def assert_filter(expected, val = nil, default = nil, allowed = [], params = {}) 7 | result = described_class.filter_param(val, params, allowed, default) 8 | expect(result).to eq expected 9 | end 10 | 11 | def assert_default(val = nil, allowed = []) 12 | assert_filter(nil, val, nil, allowed) 13 | assert_filter({id: :desc}, val, {id: :desc}, allowed) 14 | assert_filter({id: :asc}, val, {id: :asc}, allowed, sort_desc: true) 15 | assert_filter({id: :asc}, val, :id, allowed) 16 | assert_filter({id: :desc}, val, :id, allowed, sort_desc: true) 17 | assert_filter({id: :desc}, val, :id, allowed, sort_desc: 'true') 18 | assert_filter({id: :desc}, val, :id, allowed, sort_desc: '1') 19 | assert_filter({id: :asc}, val, :id, allowed, sort_desc: 'false') 20 | assert_filter({id: :asc}, val, :id, allowed, sort_desc: '0') 21 | end 22 | 23 | let(:allowed) { %i[id name] } 24 | 25 | context 'when val is not set' do 26 | it 'returns default' do 27 | assert_default 28 | end 29 | end 30 | 31 | context 'when val is scalar' do 32 | context 'and is not allowed' do 33 | it 'returns default' do 34 | assert_default(:lastname, allowed) 35 | end 36 | end 37 | 38 | it 'returns hash based on it' do 39 | assert_filter({name: :asc}, :name, nil, allowed) 40 | assert_filter({name: :desc}, :name, nil, allowed, sort_desc: true) 41 | end 42 | end 43 | 44 | context 'when val is a hash' do 45 | it 'slices only allowed keys' do 46 | assert_filter({}, {lastname: 'desc', parent: 'asc'}, nil, allowed) 47 | 48 | assert_filter({name: :desc}, {name: 'desc', parent: 'asc'}, nil, allowed) 49 | assert_filter({name: :asc}, {name: 'asc', parent: 'asc'}, nil, allowed) 50 | assert_filter({name: :asc}, {name: {smt: :else}, parent: 'asc'}, nil, allowed) 51 | 52 | assert_filter( 53 | {name: :desc, id: :desc}, 54 | {name: 'desc', id: 'desc', parent: 'asc'}, 55 | nil, 56 | allowed 57 | ) 58 | assert_filter( 59 | {name: :asc, id: :desc}, 60 | {name: 'sc', id: 'desc', parent: 'asc'}, 61 | nil, 62 | allowed 63 | ) 64 | end 65 | end 66 | end 67 | 68 | describe '.has_sort_scope' do 69 | let(:controller_class) do 70 | described_class = self.described_class 71 | Class.new(ActionController::Base) do 72 | extend described_class 73 | 74 | def action_name 75 | :index 76 | end 77 | end 78 | end 79 | let(:controller) { controller_class.new } 80 | let(:model) { Class.new(ApplicationRecord) { self.table_name = :users } } 81 | 82 | def assert_sort_query(expected, **params) 83 | allow(controller).to receive(:params) do 84 | ActionController::Parameters.new(params.as_json) 85 | end 86 | sql = controller.send(:apply_scopes, model).all.order_values.map(&:to_sql).join("\n") 87 | expect(sql).to eq expected 88 | end 89 | 90 | def assert_current_scope(expected) 91 | expect(controller.send(:current_sort_scope)).to eq expected.stringify_keys 92 | end 93 | 94 | context 'when is not called' do 95 | it 'does not sort at all' do 96 | assert_sort_query '', sort: {package_name: :desc, id: :asc} 97 | end 98 | end 99 | 100 | context 'when sorting by multiple fields is allowed' do 101 | before { controller_class.has_sort_scope by: %i[id package_name] } 102 | 103 | it 'applies all scopes' do 104 | assert_current_scope({}) 105 | assert_sort_query %("users"."package_name" ASC\n"users"."id" DESC), 106 | sort: {package_name: :asc, id: :desc, qqq: :desc} 107 | assert_current_scope package_name: :asc, id: :desc 108 | assert_sort_query '"users"."id" ASC' # default `default` is `:id` 109 | assert_current_scope id: :asc 110 | assert_sort_query '', sort: {qqq: :desc} 111 | assert_current_scope({}) 112 | end 113 | 114 | context 'and action is not index' do 115 | let(:action) { :some_action } 116 | before { allow(controller).to receive(:action_name) { action } } 117 | it 'does not sort at all' do 118 | assert_sort_query '', sort: {package_name: :desc, id: :asc} 119 | end 120 | 121 | context 'but matches :only option' do 122 | before { controller_class.has_sort_scope by: [:id], only: action } 123 | it 'applies scopes' do 124 | assert_sort_query %("users"."id" ASC), sort: {package_name: :desc, id: :asc} 125 | end 126 | end 127 | end 128 | end 129 | 130 | context 'when called multiple times' do 131 | before { controller_class.has_sort_scope by: :id } 132 | before { controller_class.has_sort_scope by: :package_name } 133 | 134 | it 'applies all scopes' do 135 | assert_sort_query %("users"."id" ASC\n"users"."package_name" DESC), 136 | sort: {package_name: :desc, id: :asc, qqq: :desc} 137 | end 138 | end 139 | 140 | context 'accepts default value' do 141 | before { controller_class.has_sort_scope by: [:id], default: :package_name } 142 | 143 | it 'and uses it' do 144 | assert_sort_query '"users"."package_name" ASC' 145 | assert_current_scope package_name: :asc 146 | assert_sort_query '"users"."package_name" DESC', sort_desc: true 147 | assert_sort_query '"users"."package_name" ASC', sort: :qqq 148 | end 149 | end 150 | 151 | context 'accepts default hash value' do 152 | before { controller_class.has_sort_scope by: [:id], default: {package_name: :desc} } 153 | 154 | it 'and uses it' do 155 | assert_sort_query '"users"."package_name" DESC' 156 | assert_current_scope package_name: :desc 157 | end 158 | end 159 | 160 | context 'when :order_method arg is given' do 161 | before do 162 | controller_class.has_sort_scope default: :id, order_method: :sort_by 163 | model.define_singleton_method(:sort_by) { |*| order(my_id: :desc) } 164 | end 165 | 166 | it 'uses this method instead of sort' do 167 | expect(model).to receive(:sort_by).with('id' => :asc).and_call_original 168 | assert_sort_query '"users"."my_id" DESC' 169 | end 170 | end 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /spec/rails_stuff/statusable/builder_spec.rb: -------------------------------------------------------------------------------- 1 | require 'support/shared/statusable' 2 | 3 | RSpec.describe RailsStuff::Statusable, :db_cleaner do 4 | include_context 'statusable' 5 | 6 | let(:model) do 7 | described_class = self.described_class 8 | build_named_class :Customer, ActiveRecord::Base do 9 | extend described_class 10 | const_set(:STATUSES, %i[confirmed banned]) 11 | has_status_field validate: false 12 | has_status_field :subscription_status, %i[pending expired], 13 | prefix: :subscription_ 14 | end 15 | end 16 | before do 17 | add_translations( 18 | status: %w[confirmed banned], 19 | subscription_status: %w[pending expired], 20 | ) 21 | end 22 | 23 | describe '##{field}=' do 24 | it 'accepts symbols' do 25 | expect { instance.status = :confirmed }. 26 | to change(instance, :status).from(nil).to('confirmed') 27 | end 28 | 29 | it 'accepts strings' do 30 | expect { instance.status = 'banned' }. 31 | to change(instance, :status).from(nil).to('banned') 32 | end 33 | 34 | context 'for custom field' do 35 | it 'accepts strings and symbols' do 36 | expect { instance.subscription_status = 'expired' }. 37 | to change(instance, :subscription_status).from(nil).to('expired') 38 | expect { instance.subscription_status = :pending }. 39 | to change(instance, :subscription_status).to('pending') 40 | end 41 | end 42 | end 43 | 44 | describe '##{field}_sym' do 45 | it 'returns status as symbol' do 46 | expect { instance.status = 'banned' }. 47 | to change(instance, :status_sym).from(nil).to(:banned) 48 | end 49 | 50 | context 'for custom field' do 51 | it 'returns status as symbol' do 52 | expect { instance.subscription_status = 'expired' }. 53 | to change(instance, :subscription_status_sym).from(nil).to(:expired) 54 | end 55 | end 56 | end 57 | 58 | describe '##{status}?' do 59 | it 'checks status' do 60 | expect { instance.status = :confirmed }. 61 | to change(instance, :confirmed?).from(false).to(true) 62 | end 63 | 64 | context 'for custom field' do 65 | it 'checks status' do 66 | expect { instance.subscription_status = :expired }. 67 | to change(instance, :subscription_expired?).from(false).to(true) 68 | end 69 | end 70 | end 71 | 72 | describe '##{status}!' do 73 | it 'updates field value' do 74 | expect(instance).to receive(:update!).with(status: 'confirmed') 75 | instance.confirmed! 76 | end 77 | 78 | context 'for custom field' do 79 | it 'updates field value' do 80 | expect(instance).to receive(:update!). 81 | with(subscription_status: 'pending') 82 | instance.subscription_pending! 83 | end 84 | end 85 | end 86 | 87 | describe '##{field}_name' do 88 | it 'returns translated status name' do 89 | expect { instance.status = :confirmed }. 90 | to change(instance, :status_name).from(nil).to('confirmed_en') 91 | end 92 | 93 | context 'for custom field' do 94 | it 'returns translated status name' do 95 | expect { instance.subscription_status = :expired }. 96 | to change(instance, :subscription_status_name).from(nil).to('expired_en') 97 | end 98 | end 99 | end 100 | 101 | describe 'validations' do 102 | context 'when validate: false' do 103 | it 'skips validations' do 104 | expect { instance.valid? }.to_not change { instance.errors[:status] }.from [] 105 | instance.status = :invalid 106 | expect { instance.valid? }.to_not change { instance.errors[:status] }.from [] 107 | end 108 | end 109 | 110 | context 'for custom field' do 111 | it 'checks valid value' do 112 | expect { instance.valid? }. 113 | to change { instance.errors[:subscription_status] }.from [] 114 | instance.subscription_status = :invalid 115 | expect { instance.valid? }. 116 | to_not change { instance.errors[:subscription_status] } 117 | instance.subscription_status = :pending 118 | expect { instance.valid? }. 119 | to change { instance.errors[:subscription_status] }.to([]) 120 | end 121 | end 122 | end 123 | 124 | describe '.with_#{field}' do 125 | it 'filters records by field values' do 126 | assert_filter(-> { where(status: :test) }) { with_status :test } 127 | assert_filter(-> { where(status: %i[test test2]) }) { with_status %i[test test2] } 128 | end 129 | 130 | context 'for custom field' do 131 | it 'filters records by field values' do 132 | assert_filter(-> { where(subscription_status: :test) }) { with_subscription_status :test } 133 | assert_filter(-> { where(subscription_status: %i[test test2]) }) do 134 | with_subscription_status %i[test test2] 135 | end 136 | end 137 | end 138 | end 139 | 140 | describe '.#{status}' do 141 | it 'filters records by field values' do 142 | assert_filter(-> { where(status: :confirmed) }) { confirmed } 143 | end 144 | 145 | context 'for status with prefix' do 146 | it 'filters records by field value' do 147 | assert_filter(-> { where(subscription_status: :pending) }) { subscription_pending } 148 | end 149 | end 150 | end 151 | 152 | describe '.not_#{status}' do 153 | it 'filters records by field values' do 154 | assert_filter(-> { where.not(status: :confirmed) }) { not_confirmed } 155 | end 156 | 157 | context 'for status with prefix' do 158 | it 'filters records by field value' do 159 | assert_filter(-> { where.not(subscription_status: :pending) }) { not_subscription_pending } 160 | end 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /spec/rails_stuff/statusable/helper_spec.rb: -------------------------------------------------------------------------------- 1 | require 'support/shared/statusable' 2 | 3 | RSpec.describe RailsStuff::Statusable::Helper, :db_cleaner do 4 | include_context 'statusable' 5 | 6 | subject { instance } 7 | let(:instance) { model.statuses } 8 | let(:statuses) { %i[confirmed banned] } 9 | let(:model) do 10 | statuses = self.statuses 11 | build_named_class :Customer, ActiveRecord::Base do 12 | extend RailsStuff::Statusable 13 | has_status_field :status, statuses, validate: false 14 | end 15 | end 16 | before { add_translations(status: %w[confirmed banned]) } 17 | 18 | its(:list) { should eq statuses } 19 | its(:list) { should be_frozen } 20 | 21 | describe '#translate' do 22 | subject { ->(val) { instance.translate(val) } } 23 | 24 | it 'returns translated status name' do 25 | expect(subject[nil]).to eq(nil) 26 | expect(subject[:confirmed]).to eq('confirmed_en') 27 | expect(subject['banned']).to eq('banned_en') 28 | end 29 | end 30 | 31 | describe '#select_options' do 32 | subject { ->(*args) { instance.select_options(*args) } } 33 | 34 | it 'returns array for options_for_select' do 35 | expect(subject.call).to contain_exactly( 36 | ['confirmed_en', :confirmed], 37 | ['banned_en', :banned], 38 | ) 39 | expect(subject[except: [:confirmed]]).to contain_exactly ['banned_en', :banned] 40 | expect(subject[only: [:confirmed]]).to contain_exactly ['confirmed_en', :confirmed] 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/rails_stuff/statusable/mapped_builder_spec.rb: -------------------------------------------------------------------------------- 1 | require 'support/shared/statusable' 2 | 3 | RSpec.describe RailsStuff::Statusable, :db_cleaner do 4 | include_context 'statusable' 5 | 6 | let(:model) do 7 | described_class = self.described_class 8 | build_named_class :Order, ActiveRecord::Base do 9 | extend described_class 10 | const_set(:STATUSES_MAPPING, confirmed: 1, rejected: 3) 11 | has_status_field validate: false 12 | has_status_field :delivery_status, 13 | mapping: {sent: 1, complete: 4}, 14 | prefix: :delivery_ 15 | has_status_field :delivery_method, {pickup: 1, local: 2, international: 3}, 16 | suffix: :_delivery 17 | end 18 | end 19 | before do 20 | add_translations( 21 | status: %w[confirmed rejected], 22 | delivery_status: %w[sent complete], 23 | delivery_method: %w[pickup local international], 24 | ) 25 | end 26 | 27 | describe '##{field}' do 28 | shared_examples 'two readers' do |mapped, original| 29 | its(:call) { should eq mapped } 30 | context 'when arg is true' do 31 | let(:args) { [true] } 32 | its(:call) { should eq original } 33 | end 34 | end 35 | 36 | shared_examples 'field reader' do |field, value| 37 | subject { -> { instance.public_send(field, *args) } } 38 | let(:args) { [] } 39 | include_examples 'two readers', nil, nil 40 | 41 | context 'when value is set' do 42 | before { instance[field] = 1 } 43 | include_examples 'two readers', value, 1 44 | 45 | context 'when value is invalid' do 46 | before { instance[field] = -1 } 47 | include_examples 'two readers', -1, -1 48 | end 49 | end 50 | end 51 | 52 | include_examples 'field reader', :status, :confirmed 53 | 54 | context 'for custom field' do 55 | include_examples 'field reader', :delivery_status, :sent 56 | end 57 | end 58 | 59 | describe '##{field}=' do 60 | shared_examples 'field writer' do |field, value| 61 | subject { ->(val) { instance.public_send("#{field}=", val) } } 62 | 63 | it 'accepts symbols' do 64 | expect { subject[value] }.to change { instance[field] }.to(1) 65 | expect { subject[:invalid] }.to change { instance[field] }.to(0) 66 | end 67 | 68 | it 'accepts strings' do 69 | expect { subject[value] }.to change { instance[field] }.to(1) 70 | expect { subject['invalid'] }.to change { instance[field] }.to(0) 71 | end 72 | 73 | it 'accepts all types' do 74 | expect { subject[55] }.to change { instance[field] }.to(55) 75 | expect { subject[nil] }.to change { instance[field] }.to(nil) 76 | end 77 | end 78 | 79 | include_examples 'field writer', :status, :confirmed 80 | 81 | context 'for custom field' do 82 | include_examples 'field writer', :delivery_status, :sent 83 | end 84 | end 85 | 86 | describe '##{field}_sym' do 87 | it 'returns status as symbol' do 88 | expect { instance.status = 'rejected' }. 89 | to change(instance, :status_sym).from(nil).to(:rejected) 90 | end 91 | 92 | context 'for custom field' do 93 | it 'returns status as symbol' do 94 | expect { instance.delivery_status = 'complete' }. 95 | to change(instance, :delivery_status_sym).from(nil).to(:complete) 96 | end 97 | end 98 | end 99 | 100 | describe '##{field}_name' do 101 | it 'returns translated status name' do 102 | expect { instance.status = :confirmed }. 103 | to change { instance.status_name }.from(nil).to('confirmed_en') 104 | end 105 | 106 | context 'for custom field' do 107 | it 'returns translated status name' do 108 | expect { instance.delivery_status = :complete }. 109 | to change { instance.delivery_status_name }.from(nil).to('complete_en') 110 | end 111 | end 112 | end 113 | 114 | describe '##{status}?' do 115 | it 'checks status' do 116 | expect { instance.status = :confirmed }. 117 | to change { instance.confirmed? }.from(false).to(true) 118 | end 119 | 120 | context 'for custom field' do 121 | it 'checks status' do 122 | expect { instance.delivery_status = :complete }. 123 | to change { instance.delivery_complete? }.from(false).to(true) 124 | end 125 | end 126 | 127 | context 'with suffix' do 128 | it 'checks status' do 129 | expect { instance.delivery_method = :local }. 130 | to change { instance.local_delivery? }.from(false).to(true) 131 | end 132 | end 133 | end 134 | 135 | describe '##{status}!' do 136 | it 'updates field value' do 137 | expect(instance).to receive(:update!).with(status: 1) 138 | instance.confirmed! 139 | end 140 | 141 | context 'for custom field' do 142 | it 'updates field value' do 143 | expect(instance).to receive(:update!).with(delivery_status: 1) 144 | instance.delivery_sent! 145 | end 146 | end 147 | 148 | context 'with suffix' do 149 | it 'updates field value' do 150 | expect(instance).to receive(:update!).with(delivery_method: 2) 151 | instance.local_delivery! 152 | end 153 | end 154 | end 155 | 156 | describe 'validations' do 157 | context 'when validate: false' do 158 | it 'skips validations' do 159 | expect { instance.valid? }.to_not change { instance.errors[:status] }.from [] 160 | instance.status = :invalid 161 | expect { instance.valid? }.to_not change { instance.errors[:status] }.from [] 162 | end 163 | end 164 | 165 | context 'for custom field' do 166 | it 'checks valid value' do 167 | expect { instance.valid? }. 168 | to change { instance.errors[:delivery_status] }.from [] 169 | instance.delivery_status = :invalid 170 | expect { instance.valid? }. 171 | to_not change { instance.errors[:delivery_status] } 172 | instance.delivery_status = :sent 173 | expect { instance.valid? }. 174 | to change { instance.errors[:delivery_status] }.to([]) 175 | end 176 | end 177 | end 178 | 179 | describe '.with_#{field}' do 180 | it 'filters records by field values' do 181 | assert_filter(-> { where(status: 1) }) { with_status :confirmed } 182 | assert_filter(-> { where(status: 3) }) { with_status 'rejected' } 183 | assert_filter(-> { where(status: 5) }) { with_status 5 } 184 | assert_filter(-> { where(status: [1, 5]) }) { with_status [1, 5] } 185 | end 186 | 187 | context 'for custom field' do 188 | it 'filters records by field values' do 189 | assert_filter(-> { where(delivery_status: 1) }) { with_delivery_status :sent } 190 | assert_filter(-> { where(delivery_status: 4) }) { with_delivery_status 'complete' } 191 | assert_filter(-> { where(delivery_status: 5) }) { with_delivery_status 5 } 192 | assert_filter(-> { where(delivery_status: [1, 5]) }) { with_delivery_status [1, 5] } 193 | end 194 | end 195 | end 196 | 197 | describe '.#{status}' do 198 | it 'filters records by field values' do 199 | assert_filter(-> { where(status: 1) }) { confirmed } 200 | assert_filter(-> { where(status: 3) }) { rejected } 201 | end 202 | 203 | context 'for status with prefix' do 204 | it 'filters records by field value' do 205 | assert_filter(-> { where(delivery_status: 1) }) { delivery_sent } 206 | assert_filter(-> { where(delivery_status: 4) }) { delivery_complete } 207 | end 208 | end 209 | 210 | context 'for status with suffix' do 211 | it 'filters records by field value' do 212 | assert_filter(-> { where(delivery_method: 1) }) { pickup_delivery } 213 | assert_filter(-> { where(delivery_method: 3) }) { international_delivery } 214 | end 215 | end 216 | end 217 | 218 | describe '.not_#{status}' do 219 | it 'filters records by field values' do 220 | assert_filter(-> { where.not(status: 1) }) { not_confirmed } 221 | end 222 | 223 | context 'for status with prefix' do 224 | it 'filters records by field value' do 225 | assert_filter(-> { where.not(delivery_status: 1) }) { not_delivery_sent } 226 | end 227 | end 228 | 229 | context 'for status with suffix' do 230 | it 'filters records by field value' do 231 | assert_filter(-> { where.not(delivery_method: 1) }) { not_pickup_delivery } 232 | end 233 | end 234 | end 235 | end 236 | -------------------------------------------------------------------------------- /spec/rails_stuff/statusable/mapped_helper_spec.rb: -------------------------------------------------------------------------------- 1 | require 'support/shared/statusable' 2 | 3 | RSpec.describe RailsStuff::Statusable::MappedHelper, :db_cleaner do 4 | include_context 'statusable' 5 | 6 | subject { instance } 7 | let(:instance) { model.statuses } 8 | let(:statuses) { {confirmed: 1, rejected: 3} } 9 | let(:model) do 10 | statuses = self.statuses 11 | build_named_class :Customer, ActiveRecord::Base do 12 | extend RailsStuff::Statusable 13 | has_status_field :status, statuses, validate: false 14 | end 15 | end 16 | before { add_translations(status: %w[confirmed rejected]) } 17 | 18 | its(:list) { should eq statuses.keys } 19 | its(:list) { should be_frozen } 20 | 21 | its(:mapping) { should eq statuses } 22 | its(:mapping) { should be_frozen } 23 | 24 | its(:inverse_mapping) { should eq statuses.invert } 25 | its(:inverse_mapping) { should be_frozen } 26 | 27 | describe '#select_options' do 28 | subject { ->(*args) { instance.select_options(*args) } } 29 | 30 | it 'returns array for options_for_select' do 31 | expect(subject.call).to contain_exactly( 32 | ['confirmed_en', :confirmed], 33 | ['rejected_en', :rejected], 34 | ) 35 | expect(subject[except: [:confirmed]]).to contain_exactly ['rejected_en', :rejected] 36 | expect(subject[only: [:confirmed]]).to contain_exactly ['confirmed_en', :confirmed] 37 | end 38 | 39 | context 'when :original is true' do 40 | subject { ->(**options) { instance.select_options(**options, original: true) } } 41 | it 'uses db values instead of mapped' do 42 | expect(subject.call).to contain_exactly( 43 | ['confirmed_en', 1], 44 | ['rejected_en', 3], 45 | ) 46 | expect(subject[except: [1]]).to contain_exactly ['rejected_en', 3] 47 | expect(subject[only: [1]]).to contain_exactly ['confirmed_en', 1] 48 | end 49 | end 50 | end 51 | 52 | describe '#map' do 53 | subject { ->(val) { instance.map(val) } } 54 | 55 | it 'maps single value' do 56 | expect(subject[:confirmed]).to eq 1 57 | expect(subject[:rejected]).to eq 3 58 | expect(subject['rejected']).to eq 3 59 | expect(subject[:missing]).to eq :missing 60 | expect(subject[nil]).to eq nil 61 | end 62 | 63 | it 'maps array' do 64 | expect(subject[[:confirmed, :rejected, 'rejected', :missing, nil]]). 65 | to eq [1, 3, 3, :missing, nil] 66 | end 67 | end 68 | 69 | describe '#unmap' do 70 | subject { ->(val) { instance.unmap(val) } } 71 | 72 | it 'maps single value' do 73 | expect(subject[1]).to eq :confirmed 74 | expect(subject[3]).to eq :rejected 75 | expect(subject[:missing]).to eq :missing 76 | expect(subject[nil]).to eq nil 77 | end 78 | 79 | it 'maps array' do 80 | expect(subject[[1, 3, :missing, nil]]).to eq [:confirmed, :rejected, :missing, nil] 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/rails_stuff/statusable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'support/shared/statusable' 2 | 3 | RSpec.describe RailsStuff::Statusable, :db_cleaner do 4 | include_context 'statusable' 5 | 6 | let(:model) do 7 | build_named_class :Customer, ActiveRecord::Base do 8 | extend RailsStuff::Statusable 9 | end 10 | end 11 | 12 | describe '.has_status_field' do 13 | subject { -> { model.has_status_field :field, %i[a b], options, &block } } 14 | let(:options) { {} } 15 | let(:block) {} 16 | it { should change { model.ancestors.size }.by(1) } 17 | it { should change(model, :instance_methods) } 18 | it { should change(model, :public_methods) } 19 | 20 | context 'for second field' do 21 | before { model.has_status_field :field_2, %i[c d] } 22 | it { should_not change { model.ancestors.size } } 23 | end 24 | 25 | context 'when block is given' do 26 | let(:block) { ->(x) { x.field_scope } } 27 | it { should_not change(model, :instance_methods) } 28 | it { should change { model.respond_to?(:with_field) }.from(false).to(true) } 29 | end 30 | 31 | context 'when builder is false' do 32 | let(:options) { {builder: false} } 33 | it { should_not change(model, :instance_methods) } 34 | it { should change(model, :public_methods).by([:fields]) } 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/rails_stuff/strong_parameters_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | require 'rails_stuff/strong_parameters' 3 | 4 | RSpec.describe ActionController::Parameters do 5 | describe 'require_permitted' do 6 | let(:input) do 7 | { 8 | a: 1, 9 | b: 'str', 10 | c: [1, 2], 11 | d: '', 12 | e: [], 13 | f: {}, 14 | g: {x: 1}, 15 | h: nil, 16 | }.stringify_keys 17 | end 18 | let(:instance) { described_class.new(input) } 19 | 20 | it 'permits params and checks they are present' do 21 | expect(instance.require_permitted('a').to_h).to eq input.slice('a') 22 | expect(instance.require_permitted('a', 'b').to_h).to eq input.slice('a', 'b') 23 | 24 | ('c'..input.keys.max).each do |field| 25 | expect { instance.require_permitted('a', field, 'b') }. 26 | to raise_error(ActionController::ParameterMissing), "failed for #{input[field]}" 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/rails_stuff/test_helpers/concurrency_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_stuff/test_helpers/concurrency' 2 | 3 | RSpec.describe RailsStuff::TestHelpers::Concurrency do 4 | describe '#concurrently' do 5 | subject { ->(*args, &block) { instance.concurrently(*args, &block) } } 6 | let(:instance) { Object.new.tap { |x| x.extend described_class } } 7 | let(:acc) { ThreadSafe::Array.new } 8 | 9 | context 'when no args given' do 10 | it 'runs block .threads_count times' do 11 | expect { subject.call { |i| acc << i } }. 12 | to change(acc, :size).from(0).to(described_class.threads_count) 13 | expect(acc).to eq Array.new(described_class.threads_count) 14 | end 15 | end 16 | 17 | context 'when arg is Integer' do 18 | let(:count) { 5 } 19 | it 'runs block arg times' do 20 | acc = ThreadSafe::Array.new 21 | expect { subject.call(count) { |i| acc << i } }. 22 | to change(acc, :size).from(0).to(count) 23 | expect(acc).to eq Array.new(count) 24 | end 25 | end 26 | 27 | context 'when arg is array' do 28 | it 'runs block for each arg' do 29 | input = [ 30 | 1, 31 | 2, 32 | {opt: true}, 33 | {opt: false}, 34 | [1, opt: false], 35 | ] 36 | expect { subject.call(input) { |arg = nil, **options| acc << [arg, options] } }. 37 | to change(acc, :size).to(input.size) 38 | expect(acc).to contain_exactly [1, {}], 39 | [2, {}], 40 | [nil, opt: true], 41 | [nil, opt: false], 42 | [1, opt: false] 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/rails_stuff/transform_attrs_spec.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/object/blank' 2 | 3 | RSpec.describe RailsStuff::TransformAttrs do 4 | let(:klass) do 5 | described_class = self.described_class 6 | Class.new do 7 | def self.extra_ancestors 8 | ancestors.take_while { |x| x != Object }. 9 | # rails 4 adds ActiveSupport::ToJsonWithActiveSupportEncoder. 10 | # We ignore it: 11 | reject(&:name) 12 | end 13 | 14 | extend described_class 15 | attr_accessor :own 16 | end 17 | end 18 | 19 | describe 'writers' do 20 | let(:instance) { klass.new } 21 | before do 22 | klass.send :include, Module.new { attr_accessor :nested } 23 | klass.transform_attrs :own, with: :strip 24 | klass.transform_attrs :nested, with: :nullify 25 | end 26 | 27 | it 'works for own attrs' do 28 | expect { instance.own = ' test' }.to change(instance, :own).to('test') 29 | expect { instance.own = ' ' }.to change(instance, :own).to('') 30 | expect { instance.own = [1] }.to change(instance, :own).to('[1]') 31 | expect { instance.own = [] }.to change(instance, :own).to('[]') 32 | end 33 | 34 | it 'works for nested attrs' do 35 | expect { instance.nested = ' test' }.to change(instance, :nested).to(' test') 36 | expect { instance.nested = ' ' }.to change(instance, :nested).to(nil) 37 | expect { instance.nested = [1] }.to change(instance, :nested).to([1]) 38 | expect { instance.nested = [] }.to change(instance, :nested).to(nil) 39 | end 40 | 41 | context 'for chained translations' do 42 | before do 43 | klass.transform_attrs :own, with: %i[strip nullify] 44 | klass.transform_attrs :nested, with: %i[nullify strip] 45 | end 46 | 47 | it 'applies them all' do 48 | expect { instance.own = ' test' }.to change(instance, :own).to('test') 49 | expect { instance.own = ' ' }.to change(instance, :own).to(nil) 50 | expect { instance.own = [] }.to change(instance, :own).to('[]') 51 | 52 | expect { instance.nested = ' test' }.to change(instance, :nested).to('test') 53 | expect { instance.nested = ' ' }.to change(instance, :nested).to(nil) 54 | expect { instance.nested = [] }.to_not change(instance, :nested).from(nil) 55 | end 56 | end 57 | end 58 | 59 | describe '.transform_attrs' do 60 | subject { ->(*args, &block) { klass.transform_attrs(*args, &block) } } 61 | 62 | it 'adds only single module by default' do 63 | expect do 64 | subject.call :one, &:presence 65 | subject.call :two, &:presence 66 | end.to change(klass, :extra_ancestors).from([klass]).to([instance_of(Module), klass]) 67 | end 68 | 69 | it 'includes new modules with `new_module:` option' do 70 | expect { subject.call :two, new_module: :include, &:presence }. 71 | to change(klass, :extra_ancestors).from([klass]). 72 | to([klass, instance_of(Module)]) 73 | expect { subject.call :one, &:presence }. 74 | to change(klass, :extra_ancestors). 75 | to([instance_of(Module), klass, instance_of(Module)]) 76 | expect { subject.call :two, new_module: :prepend, &:presence }. 77 | to change(klass, :extra_ancestors). 78 | to([instance_of(Module), instance_of(Module), klass, instance_of(Module)]) 79 | end 80 | 81 | it 'raises error when invalid translation requested' do 82 | expect { subject.call :one, with: :missing }.to raise_error(KeyError) 83 | expect { subject.call :one, with: %i[nullify missing] }.to raise_error(KeyError) 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/rails_stuff/types_tracker_spec.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | 3 | RSpec.describe RailsStuff::TypesTracker do 4 | let(:base) do 5 | described_class = self.described_class 6 | Class.new { extend described_class } 7 | end 8 | 9 | let(:child) { Class.new(base) } 10 | let(:grand_child) { Class.new(child) } 11 | let(:child_2) { Class.new(base) } 12 | 13 | def with_list_class(klass) 14 | old_klass = described_class.types_list_class 15 | described_class.types_list_class = klass 16 | yield 17 | ensure 18 | described_class.types_list_class = old_klass 19 | end 20 | 21 | # Can't do this with stubbing, 'cause class name is used before it's stubbed. 22 | module TypesTrackerTest 23 | class Project < ApplicationRecord 24 | extend RailsStuff::TypesTracker 25 | 26 | class Internal < self 27 | class Smth < self; end 28 | end 29 | 30 | class ModelName < self; end 31 | end 32 | end 33 | 34 | describe '#inherited' do 35 | it 'tracks ancestors' do 36 | expect(base.types_list).to contain_exactly child, grand_child, child_2 37 | end 38 | 39 | context 'when list_class is overwritten' do 40 | around { |ex| with_list_class(new_list_class) { ex.run } } 41 | let(:new_list_class) { Class.new(Array) } 42 | 43 | it 'tracks ancestors' do 44 | expect(base.types_list).to contain_exactly child, grand_child, child_2 45 | expect(base.types_list.class).to eq new_list_class 46 | end 47 | end 48 | end 49 | 50 | describe '#register_type' do 51 | it 'adds class to types_list' do 52 | child.unregister_type 53 | expect { child.register_type }. 54 | to change { base.types_list }.from([]).to([child]) 55 | end 56 | 57 | context 'for activerecord model' do 58 | it 'adds scope' do 59 | expect(TypesTrackerTest::Project).to respond_to :internal 60 | expect(TypesTrackerTest::Project).to respond_to :smth 61 | end 62 | 63 | it 'doesnt override methods with scope' do 64 | TypesTrackerTest::Project::ModelName.register_type 65 | expect(TypesTrackerTest::Project.model_name).to be_instance_of ActiveModel::Name 66 | end 67 | end 68 | 69 | context 'when types_list has #add method' do 70 | around { |ex| with_list_class(new_list_class) { ex.run } } 71 | let(:new_list_class) do 72 | Class.new(Hash) do 73 | def add(klass, value = :default) 74 | self[klass] = value 75 | end 76 | end 77 | end 78 | 79 | it 'uses #add method' do 80 | expect { child.register_type :best }. 81 | to change { base.types_list }. 82 | from(child => :default).to(child => :best) 83 | end 84 | end 85 | end 86 | 87 | describe '#unregister_type' do 88 | it 'removes class from types_list' do 89 | expect { child.unregister_type }. 90 | to change { base.types_list }. 91 | from([child, grand_child]).to([grand_child]) 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /spec/rails_stuff/url_for_keeping_params_spec.rb: -------------------------------------------------------------------------------- 1 | require 'integration_helper' 2 | require 'rails_stuff/url_for_keeping_params' 3 | 4 | RSpec.describe ActionDispatch::Routing::UrlFor, type: :controller do 5 | controller Site::UsersController do 6 | def index 7 | head :ok 8 | end 9 | end 10 | 11 | describe '#url_for_keeping_params' do 12 | it 'keeps query params of url' do 13 | get :index, params: {sort: :date, page: 1} 14 | expect(controller.url_for_keeping_params page: 2, limit: 5). 15 | to eq controller.url_for(sort: :date, page: 2, limit: 5) 16 | end 17 | 18 | it 'removes param when overwritten wit nil' do 19 | get :index, params: {sort: :date, page: 1} 20 | expect(controller.url_for_keeping_params page: nil, limit: 5). 21 | to eq controller.url_for(sort: :date, limit: 5) 22 | end 23 | 24 | context 'when used in a view' do 25 | controller Site::UsersController do 26 | def index 27 | render inline: '<%= raw url_for_keeping_params page: 2, limit: 5 %>' 28 | end 29 | end 30 | 31 | it 'works the same way' do 32 | get :index, params: {sort: :date, page: 1} 33 | expect(response.body). 34 | to eq controller.url_for(sort: :date, page: 2, limit: 5, only_path: true) 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/rails_stuff_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe RailsStuff do 2 | it 'has a version number' do 3 | expect(RailsStuff::VERSION).not_to be nil 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'pathname' 2 | require 'pry' 3 | require 'rspec/its' 4 | 5 | if ENV['CI'] 6 | require 'coveralls' 7 | Coveralls.wear! 8 | elsif ENV.key?('COV') 9 | require 'simplecov' 10 | SimpleCov.start 11 | end 12 | 13 | GEM_ROOT = Pathname.new File.expand_path('../..', __FILE__) 14 | 15 | $LOAD_PATH.unshift GEM_ROOT.join('lib') 16 | require 'rails_stuff' 17 | RailsStuff::TestHelpers.big_decimal 18 | 19 | RSpec.configure do |config| 20 | config.expect_with :rspec do |expectations| 21 | # This option will default to `true` in RSpec 4. It makes the `description` 22 | # and `failure_message` of custom matchers include text for helper methods 23 | # defined using `chain`, e.g.: 24 | # be_bigger_than(2).and_smaller_than(4).description 25 | # # => "be bigger than 2 and smaller than 4" 26 | # ...rather than: 27 | # # => "be bigger than 2" 28 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 29 | end 30 | 31 | config.mock_with :rspec do |mocks| 32 | # Prevents you from mocking or stubbing a method that does not exist on 33 | # a real object. This is generally recommended, and will default to 34 | # `true` in RSpec 4. 35 | mocks.verify_partial_doubles = true 36 | end 37 | 38 | # These two settings work together to allow you to limit a spec run 39 | # to individual examples or groups you care about by tagging them with 40 | # `:focus` metadata. When nothing is tagged with `:focus`, all examples 41 | # get run. 42 | # 43 | # Use `FULL=true bin/rspec` to disable filters. 44 | config.filter_run :focus 45 | config.run_all_when_everything_filtered = true 46 | 47 | # Limits the available syntax to the non-monkey patched syntax that is recommended. 48 | # For more details, see: 49 | # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax 50 | # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 51 | # - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching 52 | config.disable_monkey_patching! 53 | 54 | # Many RSpec users commonly either run the entire suite or an individual 55 | # file, and it's useful to allow more verbose output when running an 56 | # individual spec file. 57 | config.default_formatter = 'doc' if config.files_to_run.one? 58 | 59 | # Print the 10 slowest examples and example groups at the 60 | # end of the spec run, to help surface which specs are running 61 | # particularly slow. 62 | # config.profile_examples = 3 63 | 64 | # Run specs in random order to surface order dependencies. If you find an 65 | # order dependency and want to debug it, you can fix the order by providing 66 | # the seed, which is printed after each run. 67 | # --seed 1234 68 | config.order = :random 69 | 70 | # Seed global randomization in this process using the `--seed` CLI option. 71 | # Setting this allows you to use `--seed` to deterministically reproduce 72 | # test failures related to randomization by passing the same `--seed` value 73 | # as the one that triggered the failure. 74 | Kernel.srand config.seed 75 | end 76 | 77 | require 'active_support/core_ext/object/try' 78 | # Helper which builds class and defines name for it. 79 | def build_named_class(class_name, superclass = nil, &block) 80 | Class.new(superclass || Object) do 81 | define_singleton_method(:name) { class_name.try!(:to_s) } 82 | class_eval(&block) if block 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/support/active_record.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | ActiveRecord::Base.establish_connection adapter: 'sqlite3', database: ':memory:' 3 | require 'support/schema' 4 | 5 | class ApplicationRecord < ActiveRecord::Base 6 | self.abstract_class = true 7 | end 8 | 9 | require 'database_cleaner' 10 | RSpec.configure do |config| 11 | config.around db_cleaner: true do |ex| 12 | DatabaseCleaner.cleaning { ex.run } 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/support/app/views/site/forms/index.html.erb: -------------------------------------------------------------------------------- 1 | index.html.erb 2 | <%= form_for Order.new(status: :pending), url: '/' do |f| %> 3 | <%= f.select :status, Order.statuses.select_options(only: [:pending, :accepted]) %> 4 | <%- end %> 5 | 6 | <%= form_for Order.new(status: :delivered), url: '/', html: {id: 'order_2'} do |f| %> 7 | <%= f.select :status, Order.statuses.select_options(original: true, except: [1]), 8 | selected: f.object.status(true) %> 9 | <%- end %> 10 | 11 | <%= form_for Customer.new(status: :banned), url: '/' do |f| %> 12 | <%= f.select :status, Customer.statuses.select_options(except: [:premium]) %> 13 | <%- end %> 14 | -------------------------------------------------------------------------------- /spec/support/app/views/site/projects/edit.html.erb: -------------------------------------------------------------------------------- 1 | edit.html.erb 2 | -------------------------------------------------------------------------------- /spec/support/app/views/site/projects/index.html.erb: -------------------------------------------------------------------------------- 1 | index.html.erb 2 | <% collection -%> 3 | -------------------------------------------------------------------------------- /spec/support/app/views/site/users/edit.html.erb: -------------------------------------------------------------------------------- 1 | edit.html.erb 2 | <% resource -%> 3 | -------------------------------------------------------------------------------- /spec/support/app/views/site/users/index.html.erb: -------------------------------------------------------------------------------- 1 | index.html.erb 2 | -------------------------------------------------------------------------------- /spec/support/app/views/site/users/new.html.erb: -------------------------------------------------------------------------------- 1 | new.html.erb 2 | <% resource -%> 3 | -------------------------------------------------------------------------------- /spec/support/app/views/site/users/show.html.erb: -------------------------------------------------------------------------------- 1 | show.html.erb 2 | <% resource -%> 3 | -------------------------------------------------------------------------------- /spec/support/controller.rb: -------------------------------------------------------------------------------- 1 | module Support 2 | # Override process methods, so we can use rails5 specs within rails4. 3 | module ControllerBackport 4 | %w[get post put patch delete].each do |method| 5 | define_method(method) do |path, **options| 6 | params = options[:params] || {} 7 | super(path, params) 8 | end 9 | end 10 | end 11 | end 12 | 13 | RSpec.configure do |config| 14 | if RailsStuff.rails_version::MAJOR >= 5 15 | require 'rails-controller-testing' 16 | %i[controller view request].each do |type| 17 | config.include ::Rails::Controller::Testing::TestProcess, type: type 18 | config.include ::Rails::Controller::Testing::TemplateAssertions, type: type 19 | config.include ::Rails::Controller::Testing::Integration, type: type 20 | end 21 | else 22 | %i[controller request].each do |type| 23 | config.include Support::ControllerBackport, type: type 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/support/redis.rb: -------------------------------------------------------------------------------- 1 | require 'rails/railtie' 2 | require 'pooled_redis' 3 | require 'redis' 4 | Rails.instance_eval { @redis_config = {} } 5 | -------------------------------------------------------------------------------- /spec/support/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | self.verbose = false 3 | 4 | create_table :users, force: true do |t| 5 | t.string :name, null: false 6 | t.string :email, null: false 7 | t.boolean :admin, null: false, default: false 8 | 9 | t.timestamps null: false 10 | end 11 | 12 | create_table :projects, force: true do |t| 13 | t.string :name, null: false 14 | # it's nullable intentionally for tests: 15 | t.belongs_to :user, null: true, foreign_key: true, index: true 16 | t.string :type, null: false, index: true 17 | 18 | t.string :department 19 | t.string :company 20 | 21 | t.timestamps null: false 22 | end 23 | 24 | create_table :customers, force: true do |t| 25 | t.string :status, null: false 26 | t.string :subscription_status, null: false 27 | 28 | t.timestamps null: false 29 | end 30 | 31 | create_table :orders, force: true do |t| 32 | t.integer :status, null: false 33 | t.integer :delivery_status, null: false 34 | t.integer :delivery_method, null: false 35 | 36 | t.timestamps null: false 37 | end 38 | 39 | create_table :tokens, force: true do |t| 40 | t.string :code 41 | t.index :code, unique: true 42 | 43 | t.timestamps null: false 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/support/shared/statusable.rb: -------------------------------------------------------------------------------- 1 | require 'activemodel_translation/helper' 2 | require 'support/active_record' 3 | 4 | RSpec.shared_context 'statusable' do 5 | let(:instance) { model.new } 6 | 7 | def assert_filter(expected, &block) 8 | expected = relation_values(model.instance_exec(&expected)) 9 | expect(relation_values(model.instance_exec(&block))).to eq expected 10 | end 11 | 12 | if RailsStuff.rails4? 13 | def relation_values(relation) 14 | where_sql = relation.where_values.map(&:to_sql) 15 | values_sql = relation.bind_values.to_h.transform_values(&:to_s) 16 | [where_sql, values_sql] 17 | end 18 | else 19 | def relation_values(relation) 20 | where_sql = relation.where_clause.ast.to_sql 21 | values_sql = relation.where_clause.to_h.transform_values(&:to_s) 22 | [where_sql, values_sql] 23 | end 24 | end 25 | 26 | def add_translations(data) 27 | I18n.backend = I18n::Backend::Simple.new 28 | data.each do |field, values| 29 | I18n.backend.store_translations 'en', strings: { 30 | "#{field}_name" => values.map { |x| [x, "#{x}_en"] }.to_h, 31 | } 32 | end 33 | end 34 | end 35 | --------------------------------------------------------------------------------