├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE │ └── pull_request_template.md ├── dependabot.yml └── workflows │ └── ruby_on_rails.yml ├── .gitignore ├── .prettierrc.json ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Gemfile ├── Gemfile.lock ├── MIT-LICENSE ├── README.md ├── Rakefile ├── app ├── assets │ ├── config │ │ └── sail_manifest.js │ ├── images │ │ └── sail │ │ │ ├── .keep │ │ │ ├── angle-left.svg │ │ │ ├── angle-right.svg │ │ │ ├── check.svg │ │ │ ├── cog.svg │ │ │ ├── external-link-alt.svg │ │ │ ├── sail.gif │ │ │ ├── sliders-h.svg │ │ │ ├── times.svg │ │ │ └── undo.svg │ ├── javascripts │ │ └── sail │ │ │ ├── application.js │ │ │ └── settings.js │ └── stylesheets │ │ └── sail │ │ ├── application.css.erb │ │ └── settings.css ├── controllers │ └── sail │ │ ├── application_controller.rb │ │ ├── profiles_controller.rb │ │ └── settings_controller.rb ├── helpers │ └── sail │ │ └── application_helper.rb ├── models │ └── sail │ │ ├── application_record.rb │ │ ├── entry.rb │ │ ├── profile.rb │ │ └── setting.rb └── views │ ├── layouts │ └── sail │ │ └── application.html.erb │ └── sail │ ├── profiles │ ├── _profile.html.erb │ ├── create.js.erb │ ├── destroy.js.erb │ └── switch.js.erb │ └── settings │ ├── _guide_modal.html.erb │ ├── _profiles_modal.html.erb │ ├── _search.html.erb │ ├── _setting.html.erb │ ├── _sort_menu.html.erb │ ├── index.html.erb │ └── update.js.erb ├── bin └── rails ├── config ├── locales │ └── en.yml └── routes.rb ├── lib ├── false_class.rb ├── generators │ └── sail │ │ ├── install │ │ ├── install_generator.rb │ │ └── templates │ │ │ ├── create_sail_profiles.rb │ │ │ ├── create_sail_settings.rb │ │ │ └── sail.yml.tt │ │ ├── update │ │ ├── templates │ │ │ └── add_group_to_sail_settings.rb │ │ └── update_generator.rb │ │ └── views │ │ └── views_generator.rb ├── sail.rb ├── sail │ ├── configuration.rb │ ├── constant_collection.rb │ ├── engine.rb │ ├── graphql.rb │ ├── instrumenter.rb │ ├── mutations.rb │ ├── railtie.rb │ ├── types.rb │ ├── types │ │ ├── ab_test.rb │ │ ├── array.rb │ │ ├── boolean.rb │ │ ├── cron.rb │ │ ├── date.rb │ │ ├── float.rb │ │ ├── integer.rb │ │ ├── locales.rb │ │ ├── obj_model.rb │ │ ├── range.rb │ │ ├── set.rb │ │ ├── string.rb │ │ ├── throttle.rb │ │ ├── type.rb │ │ └── uri.rb │ └── version.rb ├── tasks │ └── sail_tasks.rake └── true_class.rb ├── sail.gemspec └── spec ├── controllers └── sail │ ├── profiles_controller_spec.rb │ └── settings_controller_spec.rb ├── dummy ├── Rakefile ├── app │ ├── assets │ │ ├── config │ │ │ └── manifest.js │ │ ├── images │ │ │ └── .keep │ │ ├── javascripts │ │ │ ├── application.js.erb │ │ │ ├── cable.js.erb │ │ │ └── channels │ │ │ │ └── .keep │ │ └── stylesheets │ │ │ └── application.css │ ├── channels │ │ └── application_cable │ │ │ ├── channel.rb │ │ │ └── connection.rb │ ├── controllers │ │ ├── application_controller.rb │ │ └── concerns │ │ │ └── .keep │ ├── helpers │ │ └── application_helper.rb │ ├── jobs │ │ └── application_job.rb │ ├── mailers │ │ └── application_mailer.rb │ ├── models │ │ ├── application_record.rb │ │ ├── concerns │ │ │ └── .keep │ │ ├── namespace │ │ │ └── my_model.rb │ │ ├── test.rb │ │ └── test2.rb │ └── views │ │ ├── application │ │ └── index.html.erb │ │ └── layouts │ │ ├── application.html.erb │ │ ├── mailer.html.erb │ │ └── mailer.text.erb ├── bin │ ├── bundle │ ├── rails │ ├── rake │ ├── setup │ ├── update │ └── yarn ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── cable.yml │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── initializers │ │ ├── application_controller_renderer.rb │ │ ├── assets.rb │ │ ├── backtrace_silencers.rb │ │ ├── cookies_serializer.rb │ │ ├── filter_parameter_logging.rb │ │ ├── inflections.rb │ │ ├── mime_types.rb │ │ └── wrap_parameters.rb │ ├── locales │ │ ├── en.yml │ │ └── fr.yml │ ├── puma.rb │ ├── routes.rb │ ├── sail.yml │ ├── secrets.yml │ └── spring.rb ├── db │ ├── development.sqlite3 │ ├── migrate │ │ ├── 20171026214947_create_tables_for_models.rb │ │ ├── 20180905005346_create_sail_setting.rb │ │ ├── 20181220171659_add_group_to_settings.rb │ │ ├── 20190207182505_create_sail_profiles.rb │ │ ├── 20190221151558_add_active_to_profiles.rb │ │ └── 20190606160450_remove_tests.rb │ └── schema.rb ├── lib │ └── assets │ │ └── .keep ├── log │ └── .keep ├── package.json ├── public │ ├── 404.html │ ├── 422.html │ ├── 500.html │ ├── apple-touch-icon-precomposed.png │ ├── apple-touch-icon.png │ └── favicon.ico └── tmp │ └── .keep ├── features ├── editing_settings_feature_spec.rb ├── managing_profiles_feature_spec.rb ├── quick_guide_feature_spec.rb ├── relevancy_score_feature_spec.rb ├── resetting_settings_feature_spec.rb ├── searching_settings_feature_spec.rb ├── securing_dashboard_access_feature_spec.rb ├── sorting_settings_feature_spec.rb └── viewing_settings_feature_spec.rb ├── helpers └── sail │ └── application_helper_spec.rb ├── lib ├── configuration_spec.rb ├── false_class_spec.rb ├── instrumenter_spec.rb ├── true_class_spec.rb └── types │ ├── ab_test_spec.rb │ ├── array_spec.rb │ ├── boolean_spec.rb │ ├── cron_spec.rb │ ├── date_spec.rb │ ├── float_spec.rb │ ├── integer_spec.rb │ ├── locales_spec.rb │ ├── obj_model_spec.rb │ ├── set_spec.rb │ ├── throttle_spec.rb │ ├── type_spec.rb │ └── uri_spec.rb ├── models └── sail │ ├── entry_spec.rb │ ├── profile_spec.rb │ └── setting_spec.rb ├── sail_spec.rb └── spec_helper.rb /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report an unexpected issue and help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Actual behavior** 24 | A clear and concise description of what you actually happened. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature request 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What problem are you trying to solve? Please describe.** 11 | A clear and concise description of what the problem is. 12 | 13 | Ex. I'm always frustrated when [...], I'd like to able to [...] 14 | 15 | **Describe the solution you'd like** 16 | A clear and concise description of what you want to happen. 17 | 18 | **Describe alternatives you've considered** 19 | A clear and concise description of any alternative solutions or features you've considered. 20 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### What are you trying to accomplish with this PR? 2 | 3 | 10 | 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "10:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: rails 11 | versions: 12 | - 6.1.2.1 13 | -------------------------------------------------------------------------------- /.github/workflows/ruby_on_rails.yml: -------------------------------------------------------------------------------- 1 | name: Ruby on Rails 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | rails_version: [5.2.6, 6.1.4] 11 | ruby_version: [2.5, 2.6, 2.7, 3.0] 12 | exclude: 13 | - rails_version: 5.2.6 14 | ruby_version: 3.0 15 | 16 | steps: 17 | - uses: actions/checkout@v2.3.4 18 | - name: Cache 19 | uses: actions/cache@v2.1.6 20 | with: 21 | path: vendor/bundle 22 | key: ${{ matrix.ruby_version }}_${{ matrix.rails_version }} 23 | - name: Set up Ruby 24 | uses: ruby/setup-ruby@v1.77.0 25 | with: 26 | ruby-version: ${{ matrix.ruby_version }} 27 | - name: Build and test with Rake 28 | run: | 29 | sudo apt-get install libsqlite3-dev 30 | rm Gemfile.lock 31 | gem install bundler 32 | bundle install --path vendor/bundle --jobs 4 --retry 3 33 | bundle exec rake 34 | env: 35 | RAILS_VERSION: ${{ matrix.rails_version }} 36 | ON_CI: true 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | log/*.log 3 | pkg/ 4 | spec/dummy/db/*.sqlite3 5 | spec/dummy/db/*.sqlite3-journal 6 | spec/dummy/log/*.log 7 | spec/dummy/tmp/ 8 | .idea/ 9 | /coverage/ 10 | .ruby-version 11 | .ruby-gemset 12 | .byebug_history 13 | .DS_Store 14 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4 3 | } 4 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format progress 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-packaging 3 | - rubocop-performance 4 | - rubocop-rails 5 | 6 | AllCops: 7 | TargetRubyVersion: 2.5 8 | NewCops: enable 9 | Exclude: 10 | - spec/dummy/**/* 11 | - coverage/**/* 12 | - lib/generators/sail/**/templates/**/* 13 | - vendor/bundle/**/* 14 | Rails: 15 | Enabled: true 16 | Rails/HttpPositionalArguments: 17 | Exclude: 18 | - spec/controllers/**/* 19 | Layout/LineLength: 20 | Max: 140 21 | Metrics/MethodLength: 22 | Max: 20 23 | Metrics/BlockLength: 24 | Max: 30 25 | Exclude: 26 | - spec/**/* 27 | Metrics/ClassLength: 28 | Max: 175 29 | Style/GuardClause: 30 | Enabled: false 31 | Style/StringLiterals: 32 | EnforcedStyle: double_quotes 33 | Style/StringLiteralsInInterpolation: 34 | Enabled: false 35 | Style/OptionalBooleanParameter: 36 | Enabled: false 37 | Naming/VariableNumber: 38 | Enabled: false 39 | Style/RedundantFreeze: 40 | Enabled: false 41 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | Firstly, all contributions are greatly appreciated. Be it opening an issue proposing an improvement, submitting a pull request fixing a bug or any other assistance is helpful. 4 | 5 | Some examples of how to contribute can be found in the [wiki]. 6 | 7 | ## Issues 8 | 9 | If you have found a bug or have a suggestion, please make a brief search in the issues section to check if something similar has not been reported already. 10 | 11 | **Bugs** 12 | 13 | When describing bugs in the gem, please include steps for reproducing the issue. That makes it easier to identify the problem's source and solve it. 14 | 15 | **Suggestions** 16 | 17 | These are mostly pretty open, but try to be concise in your explanation of what is being suggested. 18 | 19 | ## Pull requests 20 | 21 | If you're addressing an issue, please refer to it in your pull request's comment for tracking. 22 | 23 | 1. Fork it ( https://github.com/vinistock/sail/fork ) 24 | 2. Create your feature branch (git checkout -b my-feature) 25 | 3. Commit your changes (git commit -am 'Add my feature') 26 | 4. Push to the branch (git push origin my-feature) 27 | 5. Create a new Pull Request 28 | 29 | [wiki]: https://github.com/vinistock/sail/wiki 30 | 31 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | gemspec 5 | 6 | gem "bundler" 7 | gem "byebug", platforms: %i[mri mingw x64_mingw] 8 | gem "capybara" 9 | gem "capybara-selenium" 10 | gem "database_cleaner" 11 | gem "rack-mini-profiler" 12 | gem "rails", (ENV["RAILS_VERSION"] || ">= 4.0.0") 13 | gem "rspec-rails", (ENV["RAILS_VERSION"].nil? || ENV["RAILS_VERSION"].to_s >= "6.0.0" ? ">= 4.0.0" : ">= 3.8.0") 14 | gem "rspec-retry" 15 | gem "rubocop" 16 | gem "rubocop-packaging" 17 | gem "rubocop-performance" 18 | gem "rubocop-rails" 19 | gem "sassc-rails" 20 | gem "sqlite3", 21 | (ENV["RAILS_VERSION"].nil? || ENV["RAILS_VERSION"].to_s >= "6.0.0" ? ">= 1.4.0" : "< 1.4.0"), 22 | platforms: %i[mri mingw x64_mingw] 23 | gem "webdrivers" 24 | gem "webrick" 25 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | sail (3.6.1) 5 | fugit 6 | rails (>= 5.0.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | actioncable (6.1.5) 12 | actionpack (= 6.1.5) 13 | activesupport (= 6.1.5) 14 | nio4r (~> 2.0) 15 | websocket-driver (>= 0.6.1) 16 | actionmailbox (6.1.5) 17 | actionpack (= 6.1.5) 18 | activejob (= 6.1.5) 19 | activerecord (= 6.1.5) 20 | activestorage (= 6.1.5) 21 | activesupport (= 6.1.5) 22 | mail (>= 2.7.1) 23 | actionmailer (6.1.5) 24 | actionpack (= 6.1.5) 25 | actionview (= 6.1.5) 26 | activejob (= 6.1.5) 27 | activesupport (= 6.1.5) 28 | mail (~> 2.5, >= 2.5.4) 29 | rails-dom-testing (~> 2.0) 30 | actionpack (6.1.5) 31 | actionview (= 6.1.5) 32 | activesupport (= 6.1.5) 33 | rack (~> 2.0, >= 2.0.9) 34 | rack-test (>= 0.6.3) 35 | rails-dom-testing (~> 2.0) 36 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 37 | actiontext (6.1.5) 38 | actionpack (= 6.1.5) 39 | activerecord (= 6.1.5) 40 | activestorage (= 6.1.5) 41 | activesupport (= 6.1.5) 42 | nokogiri (>= 1.8.5) 43 | actionview (6.1.5) 44 | activesupport (= 6.1.5) 45 | builder (~> 3.1) 46 | erubi (~> 1.4) 47 | rails-dom-testing (~> 2.0) 48 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 49 | activejob (6.1.5) 50 | activesupport (= 6.1.5) 51 | globalid (>= 0.3.6) 52 | activemodel (6.1.5) 53 | activesupport (= 6.1.5) 54 | activerecord (6.1.5) 55 | activemodel (= 6.1.5) 56 | activesupport (= 6.1.5) 57 | activestorage (6.1.5) 58 | actionpack (= 6.1.5) 59 | activejob (= 6.1.5) 60 | activerecord (= 6.1.5) 61 | activesupport (= 6.1.5) 62 | marcel (~> 1.0) 63 | mini_mime (>= 1.1.0) 64 | activesupport (6.1.5) 65 | concurrent-ruby (~> 1.0, >= 1.0.2) 66 | i18n (>= 1.6, < 2) 67 | minitest (>= 5.1) 68 | tzinfo (~> 2.0) 69 | zeitwerk (~> 2.3) 70 | addressable (2.8.0) 71 | public_suffix (>= 2.0.2, < 5.0) 72 | ast (2.4.2) 73 | builder (3.2.4) 74 | byebug (11.1.3) 75 | capybara (3.35.3) 76 | addressable 77 | mini_mime (>= 0.1.3) 78 | nokogiri (~> 1.8) 79 | rack (>= 1.6.0) 80 | rack-test (>= 0.6.3) 81 | regexp_parser (>= 1.5, < 3.0) 82 | xpath (~> 3.2) 83 | capybara-selenium (0.0.6) 84 | capybara 85 | selenium-webdriver 86 | childprocess (3.0.0) 87 | concurrent-ruby (1.1.9) 88 | crass (1.0.6) 89 | database_cleaner (2.0.1) 90 | database_cleaner-active_record (~> 2.0.0) 91 | database_cleaner-active_record (2.0.0) 92 | activerecord (>= 5.a) 93 | database_cleaner-core (~> 2.0.0) 94 | database_cleaner-core (2.0.1) 95 | diff-lcs (1.5.0) 96 | erubi (1.10.0) 97 | et-orbi (1.2.6) 98 | tzinfo 99 | ffi (1.14.2) 100 | fugit (1.5.2) 101 | et-orbi (~> 1.1, >= 1.1.8) 102 | raabro (~> 1.4) 103 | globalid (1.0.0) 104 | activesupport (>= 5.0) 105 | i18n (1.10.0) 106 | concurrent-ruby (~> 1.0) 107 | loofah (2.14.0) 108 | crass (~> 1.0.2) 109 | nokogiri (>= 1.5.9) 110 | mail (2.7.1) 111 | mini_mime (>= 0.1.1) 112 | marcel (1.0.2) 113 | method_source (1.0.0) 114 | mini_mime (1.1.2) 115 | mini_portile2 (2.6.1) 116 | minitest (5.15.0) 117 | nio4r (2.5.8) 118 | nokogiri (1.12.5) 119 | mini_portile2 (~> 2.6.1) 120 | racc (~> 1.4) 121 | nokogiri (1.12.5-arm64-darwin) 122 | racc (~> 1.4) 123 | parallel (1.22.0) 124 | parser (3.1.1.0) 125 | ast (~> 2.4.1) 126 | public_suffix (4.0.6) 127 | raabro (1.4.0) 128 | racc (1.6.0) 129 | rack (2.2.3) 130 | rack-mini-profiler (3.0.0) 131 | rack (>= 1.2.0) 132 | rack-test (1.1.0) 133 | rack (>= 1.0, < 3) 134 | rails (6.1.5) 135 | actioncable (= 6.1.5) 136 | actionmailbox (= 6.1.5) 137 | actionmailer (= 6.1.5) 138 | actionpack (= 6.1.5) 139 | actiontext (= 6.1.5) 140 | actionview (= 6.1.5) 141 | activejob (= 6.1.5) 142 | activemodel (= 6.1.5) 143 | activerecord (= 6.1.5) 144 | activestorage (= 6.1.5) 145 | activesupport (= 6.1.5) 146 | bundler (>= 1.15.0) 147 | railties (= 6.1.5) 148 | sprockets-rails (>= 2.0.0) 149 | rails-dom-testing (2.0.3) 150 | activesupport (>= 4.2.0) 151 | nokogiri (>= 1.6) 152 | rails-html-sanitizer (1.4.2) 153 | loofah (~> 2.3) 154 | railties (6.1.5) 155 | actionpack (= 6.1.5) 156 | activesupport (= 6.1.5) 157 | method_source 158 | rake (>= 12.2) 159 | thor (~> 1.0) 160 | rainbow (3.1.1) 161 | rake (13.0.6) 162 | regexp_parser (2.2.1) 163 | rexml (3.2.5) 164 | rspec-core (3.11.0) 165 | rspec-support (~> 3.11.0) 166 | rspec-expectations (3.11.0) 167 | diff-lcs (>= 1.2.0, < 2.0) 168 | rspec-support (~> 3.11.0) 169 | rspec-mocks (3.11.0) 170 | diff-lcs (>= 1.2.0, < 2.0) 171 | rspec-support (~> 3.11.0) 172 | rspec-rails (5.1.1) 173 | actionpack (>= 5.2) 174 | activesupport (>= 5.2) 175 | railties (>= 5.2) 176 | rspec-core (~> 3.10) 177 | rspec-expectations (~> 3.10) 178 | rspec-mocks (~> 3.10) 179 | rspec-support (~> 3.10) 180 | rspec-retry (0.6.2) 181 | rspec-core (> 3.3) 182 | rspec-support (3.11.0) 183 | rubocop (1.26.1) 184 | parallel (~> 1.10) 185 | parser (>= 3.1.0.0) 186 | rainbow (>= 2.2.2, < 4.0) 187 | regexp_parser (>= 1.8, < 3.0) 188 | rexml 189 | rubocop-ast (>= 1.16.0, < 2.0) 190 | ruby-progressbar (~> 1.7) 191 | unicode-display_width (>= 1.4.0, < 3.0) 192 | rubocop-ast (1.16.0) 193 | parser (>= 3.1.1.0) 194 | rubocop-packaging (0.5.1) 195 | rubocop (>= 0.89, < 2.0) 196 | rubocop-performance (1.13.3) 197 | rubocop (>= 1.7.0, < 2.0) 198 | rubocop-ast (>= 0.4.0) 199 | rubocop-rails (2.14.2) 200 | activesupport (>= 4.2.0) 201 | rack (>= 1.1) 202 | rubocop (>= 1.7.0, < 2.0) 203 | ruby-progressbar (1.11.0) 204 | rubyzip (2.3.2) 205 | sassc (2.4.0) 206 | ffi (~> 1.9) 207 | sassc-rails (2.1.2) 208 | railties (>= 4.0.0) 209 | sassc (>= 2.0) 210 | sprockets (> 3.0) 211 | sprockets-rails 212 | tilt 213 | selenium-webdriver (3.142.7) 214 | childprocess (>= 0.5, < 4.0) 215 | rubyzip (>= 1.2.2) 216 | sprockets (4.0.3) 217 | concurrent-ruby (~> 1.0) 218 | rack (> 1, < 3) 219 | sprockets-rails (3.4.2) 220 | actionpack (>= 5.2) 221 | activesupport (>= 5.2) 222 | sprockets (>= 3.0.0) 223 | sqlite3 (1.4.2) 224 | thor (1.2.1) 225 | tilt (2.0.10) 226 | tzinfo (2.0.4) 227 | concurrent-ruby (~> 1.0) 228 | unicode-display_width (2.1.0) 229 | webdrivers (4.6.1) 230 | nokogiri (~> 1.6) 231 | rubyzip (>= 1.3.0) 232 | selenium-webdriver (>= 3.0, < 4.0) 233 | webrick (1.7.0) 234 | websocket-driver (0.7.5) 235 | websocket-extensions (>= 0.1.0) 236 | websocket-extensions (0.1.5) 237 | xpath (3.2.0) 238 | nokogiri (~> 1.8) 239 | zeitwerk (2.5.4) 240 | 241 | PLATFORMS 242 | arm64-darwin-20 243 | ruby 244 | 245 | DEPENDENCIES 246 | bundler 247 | byebug 248 | capybara 249 | capybara-selenium 250 | database_cleaner 251 | rack-mini-profiler 252 | rails (>= 4.0.0) 253 | rspec-rails (>= 4.0.0) 254 | rspec-retry 255 | rubocop 256 | rubocop-packaging 257 | rubocop-performance 258 | rubocop-rails 259 | sail! 260 | sassc-rails 261 | sqlite3 (>= 1.4.0) 262 | webdrivers 263 | webrick 264 | 265 | BUNDLED WITH 266 | 2.2.31 267 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2021 Vinicius Stock 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | The icons used by Sail's admin panel (all SVG files under app/assets/images/sail) 23 | are all made by Font Awesome. The original license can be found below. 24 | 25 | https://github.com/FortAwesome/Font-Awesome 26 | 27 | ## Font Awesome Free License 28 | 29 | Font Awesome Free is free, open source, and GPL friendly. You can use it for 30 | commercial projects, open source projects, or really almost whatever you want. 31 | Full Font Awesome Free license: https://fontawesome.com/license/free. 32 | 33 | # Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/) 34 | 35 | In the Font Awesome Free download, the CC BY 4.0 license applies to all icons 36 | packaged as SVG and JS file types. 37 | 38 | # Fonts: SIL OFL 1.1 License (https://scripts.sil.org/OFL) 39 | 40 | In the Font Awesome Free download, the SIL OFL license applies to all icons 41 | packaged as web and desktop font files. 42 | 43 | # Code: MIT License (https://opensource.org/licenses/MIT) 44 | 45 | In the Font Awesome Free download, the MIT license applies to all non-font and 46 | non-icon files. 47 | 48 | # Attribution 49 | 50 | Attribution is required by MIT, SIL OFL, and CC BY licenses. Downloaded Font 51 | Awesome Free files already contain embedded comments with sufficient 52 | attribution, so you shouldn't need to do anything additional when using these 53 | files normally. 54 | 55 | We've kept attribution comments terse, so we ask that you do not actively work 56 | to remove them from files, especially code. They're a great way for folks to 57 | learn about Font Awesome. 58 | 59 | # Brand Icons 60 | 61 | All brand icons are trademarks of their respective owners. The use of these 62 | trademarks does not indicate endorsement of the trademark holder by Font 63 | Awesome, nor vice versa. **Please do not use brand logos for any purpose except 64 | to represent the company, product, or service to which they refer.** 65 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | # frozen_string_literal: true 3 | 4 | begin 5 | require "bundler/setup" 6 | rescue LoadError 7 | puts "You must `gem install bundler` and `bundle install` to run rake tasks" 8 | end 9 | 10 | APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__) 11 | load "rails/tasks/engine.rake" 12 | 13 | Bundler::GemHelper.install_tasks 14 | 15 | Dir[File.join(File.dirname(__FILE__), "tasks/**/*.rake")].each { |f| load f } 16 | 17 | require "rspec/core" 18 | require "rspec/core/rake_task" 19 | require "rubocop/rake_task" 20 | 21 | RSpec::Core::RakeTask.new(spec: "app:db:test:prepare") 22 | 23 | RuboCop::RakeTask.new 24 | 25 | task default: %i[spec rubocop] 26 | 27 | system("cd ./spec/dummy; RAILS_ENV=test rails db:environment:set; cd ../..") 28 | -------------------------------------------------------------------------------- /app/assets/config/sail_manifest.js: -------------------------------------------------------------------------------- 1 | //= link_directory ../javascripts/sail .js 2 | //= link_directory ../stylesheets/sail .css 3 | -------------------------------------------------------------------------------- /app/assets/images/sail/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinistock/sail/65f7bcadd155fff87088d15730356cd5ce33dd17/app/assets/images/sail/.keep -------------------------------------------------------------------------------- /app/assets/images/sail/angle-left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/sail/angle-right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/sail/check.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/sail/cog.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/sail/external-link-alt.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/sail/sail.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinistock/sail/65f7bcadd155fff87088d15730356cd5ce33dd17/app/assets/images/sail/sail.gif -------------------------------------------------------------------------------- /app/assets/images/sail/sliders-h.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/sail/times.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/sail/undo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/javascripts/sail/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. JavaScript code in this file should be added after the last require_* statement. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require rails-ujs 14 | //= require_tree . 15 | -------------------------------------------------------------------------------- /app/assets/javascripts/sail/settings.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | Search related functions 5 | */ 6 | 7 | let submitTimer, 8 | submitInterval, 9 | intervals = 1; 10 | let queryElement = document.getElementById("query"); 11 | let autoSearchEnabled = document.getElementById("auto_search_enabled").value; 12 | let progress = document.getElementById("search-submit-progress"); 13 | let sortMenu = document.getElementById("sort-menu"); 14 | let orderButton = document.getElementById("btn-order"); 15 | let profilesMenu = document.getElementById("profiles-modal"); 16 | let profilesButton = document.getElementById("btn-profiles"); 17 | let dashboardBody = document.getElementById("settings-dashboard"); 18 | let guideButton = document.getElementById("btn-guide"); 19 | let guide = document.getElementById("guide-modal"); 20 | let guideSections = guide.getElementsByTagName("summary"); 21 | let cardTitles = document.getElementsByClassName("card-title"); 22 | let inputs = document.getElementsByName("value"); 23 | const initialSettingValues = {}; 24 | let i; 25 | 26 | function submitSearch() { 27 | document.getElementById("search-form").submit(); 28 | } 29 | 30 | function advanceProgress() { 31 | progress.value = intervals; 32 | intervals += 1; 33 | } 34 | 35 | function clearTimer() { 36 | clearTimeout(submitTimer); 37 | clearTimeout(submitInterval); 38 | intervals = 1; 39 | } 40 | 41 | function afterTypingQuery() { 42 | progress.style.display = "inline-block"; 43 | clearTimer(); 44 | submitTimer = setTimeout(submitSearch, 2000); 45 | submitInterval = setInterval(advanceProgress, 20); 46 | } 47 | 48 | function toggleSortMenu() { 49 | if (sortMenu.style.display === "none") { 50 | sortMenu.style.display = "block"; 51 | } else { 52 | sortMenu.style.display = "none"; 53 | } 54 | } 55 | 56 | function toggleModal(modal) { 57 | if (modal.style.display === "none") { 58 | dashboardBody.style.filter = "opacity(70%) blur(2px)"; 59 | 60 | document.body.style.filter = "alpha(opacity=60)"; 61 | modal.style.display = "block"; 62 | } else { 63 | modal.style.display = "none"; 64 | dashboardBody.style.filter = "none"; 65 | } 66 | } 67 | 68 | function handleGenericClick(event) { 69 | let target = event.target; 70 | 71 | if ( 72 | orderButton !== null && 73 | (target === sortMenu || 74 | sortMenu.contains(target) || 75 | target === orderButton || 76 | orderButton.contains(target)) 77 | ) { 78 | return; 79 | } 80 | 81 | if ( 82 | profilesButton !== null && 83 | (target === profilesMenu || 84 | profilesMenu.contains(target) || 85 | target === profilesButton || 86 | profilesButton.contains(target)) 87 | ) { 88 | return; 89 | } 90 | 91 | if (target === guide || guide.contains(target) || target === guideButton) { 92 | return; 93 | } 94 | 95 | if (sortMenu !== null) sortMenu.style.display = "none"; 96 | profilesMenu.style.display = "none"; 97 | guide.style.display = "none"; 98 | dashboardBody.style.filter = "none"; 99 | } 100 | 101 | function closeAllModals(event) { 102 | if (event.key === "Escape") { 103 | if (sortMenu !== null) sortMenu.style.display = "none"; 104 | profilesMenu.style.display = "none"; 105 | guide.style.display = "none"; 106 | dashboardBody.style.filter = "none"; 107 | } 108 | } 109 | 110 | if (queryElement !== null) { 111 | if (autoSearchEnabled === "true") { 112 | queryElement.addEventListener("keyup", afterTypingQuery); 113 | queryElement.addEventListener("keydown", clearTimer); 114 | } 115 | 116 | orderButton.addEventListener("click", toggleSortMenu); 117 | profilesButton.addEventListener("click", function () { 118 | toggleModal(profilesMenu); 119 | }); 120 | } 121 | 122 | guideButton.addEventListener("click", function () { 123 | toggleModal(guide); 124 | }); 125 | document.body.addEventListener("click", handleGenericClick); 126 | document.addEventListener("keydown", closeAllModals); 127 | 128 | /* 129 | Refresh related functions 130 | */ 131 | 132 | let refreshButtons = document.getElementsByClassName("refresh-button"); 133 | 134 | function refreshClick() { 135 | let button = this; 136 | 137 | if (!button.className.includes("active")) { 138 | button.classList.add("active"); 139 | setTimeout(function () { 140 | button.classList.remove("active"); 141 | }, 500); 142 | } 143 | } 144 | 145 | for (i = 0; i < refreshButtons.length; i++) 146 | refreshButtons[i].addEventListener("click", refreshClick); 147 | 148 | /* 149 | Guide related functions 150 | */ 151 | 152 | function sectionClick() { 153 | for (i = 0; i < guideSections.length; i++) { 154 | if (this.parentElement.open) { 155 | guideSections[i].parentElement.style.display = "block"; 156 | } else if (this !== guideSections[i]) { 157 | guideSections[i].parentElement.style.display = "none"; 158 | } 159 | } 160 | } 161 | 162 | for (i = 0; i < guideSections.length; i++) 163 | guideSections[i].addEventListener("click", sectionClick); 164 | 165 | /* 166 | Cards related functions 167 | */ 168 | 169 | function flipCard() { 170 | this.parentElement.parentElement.classList.toggle("flipped"); 171 | } 172 | 173 | for (i = 0; i < cardTitles.length; i++) 174 | cardTitles[i].addEventListener("click", flipCard); 175 | 176 | function enableSubmitButton() { 177 | const name = this.id.replace("input_for_", ""); 178 | const submitId = this.id.replace("input_for_", "btn-submit-"); 179 | const submit = document.getElementById(submitId); 180 | const value = this.type === "checkbox" ? this.checked : this.value; 181 | 182 | if (value === initialSettingValues[name]) { 183 | submit.classList.remove("orange"); 184 | submit.disabled = true; 185 | } else { 186 | submit.classList.add("orange"); 187 | submit.disabled = false; 188 | } 189 | } 190 | 191 | for (i = 0; i < inputs.length; i++) { 192 | if (inputs[i].type === "text") { 193 | inputs[i].addEventListener("input", enableSubmitButton); 194 | } else { 195 | inputs[i].addEventListener("change", enableSubmitButton); 196 | } 197 | 198 | if (inputs[i].type === "checkbox") { 199 | initialSettingValues[inputs[i].id.replace("input_for_", "")] = 200 | inputs[i].checked; 201 | } else { 202 | initialSettingValues[inputs[i].id.replace("input_for_", "")] = 203 | inputs[i].value; 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /app/assets/stylesheets/sail/application.css.erb: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | 17 | :root { 18 | --main-black: #0B0C10; 19 | --dark-green: #306B34; 20 | --light-green: #44AF69; 21 | --cerulean: #4484CE; 22 | --aluminium: #D9D9D9; 23 | --dark-aluminium: #8D8D8D; 24 | --darker-aluminium: #666666; 25 | --light-yellow: #F9CF00; 26 | --tangerine: #F19F4D; 27 | --dark-tangerine: #C86C10; 28 | --lead: #003049; 29 | --bright-red: #E63946; 30 | } 31 | 32 | @-webkit-keyframes fadeIn { 33 | from { opacity: 0; } 34 | to { opacity: 1; } 35 | } 36 | 37 | @keyframes fadeIn { 38 | from { opacity: 0; } 39 | to { opacity: 1; } 40 | } 41 | 42 | * { 43 | font-family: 'Open Sans', sans-serif; 44 | -webkit-font-smoothing: antialiased; 45 | -moz-osx-font-smoothing: grayscale; 46 | } 47 | 48 | .title { 49 | font-family: 'Montserrat', sans-serif; 50 | } 51 | 52 | html, body { 53 | height: 100vh; 54 | margin: 0; 55 | padding: 0; 56 | background-color: var(--cerulean); 57 | } 58 | 59 | .clearfix { 60 | clear: both; 61 | } 62 | 63 | #nav-bar { 64 | background-color: var(--lead); 65 | box-shadow: 0 5px 10px rgba(0, 0, 0, 0.15), 0 5px 12px rgba(0, 0, 0, 0.1); 66 | } 67 | 68 | #nav-bar .home-link { 69 | -webkit-transition : color .25s ease-in; 70 | -moz-transition : color .25s ease-in; 71 | -o-transition : color .25s ease-in; 72 | transition : color .25s ease-in; 73 | color: white; 74 | text-decoration: none; 75 | } 76 | 77 | #nav-bar .home-link:visited { 78 | color: white; 79 | } 80 | 81 | #nav-bar .home-link:hover { 82 | color: var(--tangerine); 83 | } 84 | 85 | #nav-bar .home-link .title { 86 | margin: 0; 87 | text-align: center; 88 | font-size: 3rem; 89 | padding: .75rem 0; 90 | } 91 | 92 | #nav-bar .nav-button { 93 | float: right; 94 | position: relative; 95 | bottom: 50px; 96 | font-size: 20px; 97 | outline: none; 98 | background: transparent; 99 | border: none; 100 | -webkit-transition : color .25s ease-in; 101 | -moz-transition : color .25s ease-in; 102 | -o-transition : color .25s ease-in; 103 | transition : color .25s ease-in; 104 | color: white; 105 | text-decoration: none; 106 | } 107 | 108 | #nav-bar .nav-button:visited { 109 | color: white; 110 | } 111 | 112 | #nav-bar .nav-button:hover { 113 | color: var(--tangerine); 114 | } 115 | 116 | #nav-bar .nav-button:hover { 117 | cursor: pointer; 118 | } 119 | 120 | #nav-bar #btn-guide { 121 | right: 70px; 122 | padding: 0; 123 | margin: 0; 124 | } 125 | 126 | @media (min-width: 1200px) { 127 | #nav-bar #btn-guide { 128 | right: 110px; 129 | } 130 | } 131 | 132 | @media (max-width: 991px) { 133 | #nav-bar .nav-button { 134 | display: none; 135 | } 136 | } 137 | 138 | #pagination { 139 | text-align: center; 140 | margin-top: 2rem; 141 | } 142 | 143 | #pagination a { 144 | color: var(--main-black); 145 | text-decoration: none; 146 | background-color: white; 147 | padding: 20px; 148 | -webkit-border-radius: 5px; 149 | -moz-border-radius: 5px; 150 | border-radius: 5px; 151 | box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); 152 | font-weight: bold; 153 | } 154 | 155 | #pagination a.active { 156 | background-color: var(--tangerine); 157 | color: white; 158 | } 159 | 160 | #pagination .page-links a { 161 | font-size: 1.2rem; 162 | margin: 0 2px 0 2px; 163 | } 164 | 165 | #pagination .page-links #angle-left-link { 166 | background: white url(<%= asset_url("sail/angle-left.svg") %>) center no-repeat; 167 | margin-right: 6px; 168 | padding: 20px 24px 20px 24px; 169 | } 170 | 171 | #pagination .page-links #angle-right-link { 172 | background: white url(<%= asset_url("sail/angle-right.svg") %>) center no-repeat; 173 | margin-left: 6px; 174 | padding: 20px 24px 20px 24px; 175 | } 176 | 177 | #profiles-modal, #guide-modal { 178 | position: fixed; 179 | height: 70%; 180 | width: 70%; 181 | top: 15%; 182 | left: 15%; 183 | background-color: white; 184 | z-index: 1; 185 | box-shadow: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23); 186 | -webkit-border-radius: 5px; 187 | -moz-border-radius: 5px; 188 | border-radius: 5px; 189 | -webkit-animation: fadeIn 0.5s; 190 | animation: fadeIn 0.5s; 191 | padding: 15px; 192 | overflow: scroll; 193 | } 194 | 195 | @media (max-width: 1200px) { 196 | #profiles-modal, #guide-modal { 197 | width: 90%; 198 | left: 3%; 199 | } 200 | } 201 | 202 | @media (max-width: 767px) { 203 | #profiles-modal, #guide-modal { 204 | width: 90%; 205 | left: 1%; 206 | } 207 | } 208 | 209 | #profiles-modal { 210 | text-align: center; 211 | } 212 | 213 | #profiles-modal h1 { 214 | margin-left: 35px; 215 | } 216 | 217 | #profiles-modal h1 #response-message { 218 | float: right; 219 | font-size: 18px; 220 | margin-right: 3px; 221 | position: relative; 222 | top: 5px; 223 | min-width: 50px; 224 | min-height: 20px; 225 | color: var(--dark-green); 226 | } 227 | 228 | #profiles-modal .profile-entry { 229 | padding: 10px; 230 | font-size: 20px; 231 | border-bottom: 2px solid transparent; 232 | margin-bottom: 15px; 233 | -webkit-transition : border .25s ease-in; 234 | -moz-transition : border .25s ease-in; 235 | -o-transition : border .25s ease-in; 236 | transition : border .25s ease-in; 237 | outline: none; 238 | } 239 | 240 | #profiles-modal .profile-entry:focus, 241 | #profiles-modal .profile-entry:hover { 242 | border-color: var(--tangerine); 243 | outline: none; 244 | } 245 | 246 | #profiles-modal .profile-entry button { 247 | padding: 10px; 248 | } 249 | 250 | #profiles-modal .profile-entry .entry-name { 251 | position: relative; 252 | top: 10px; 253 | } 254 | 255 | #profiles-modal .buttons button { 256 | margin: 0 5px 0 5px; 257 | } 258 | 259 | @media (max-width: 767px) { 260 | #profiles-modal .profile-entry .entry-name { 261 | width: 90%; 262 | margin-bottom: 15px; 263 | } 264 | 265 | #profiles-modal #new-profile-input { 266 | width: 70%; 267 | } 268 | } 269 | 270 | #profiles-modal .profile-entry .active-indicator { 271 | position: relative; 272 | top: 7px; 273 | } 274 | 275 | #profiles-modal .profile-entry .active-indicator.yellow { 276 | color: var(--light-yellow); 277 | } 278 | 279 | #profiles-modal .profile-entry .active-indicator.green { 280 | color: var(--light-green); 281 | } 282 | 283 | #profiles-modal .profile-entry .errors-indicator { 284 | position: relative; 285 | top: 9px; 286 | left: 15px; 287 | font-style: italic; 288 | color: var(--dark-aluminium); 289 | } 290 | 291 | #profiles-modal #new-profile-input { 292 | height: 100%; 293 | border: none; 294 | font-size: 20px; 295 | width: 90%; 296 | position: relative; 297 | top: 10px; 298 | text-overflow: ellipsis; 299 | } 300 | 301 | #profiles-modal #new-profile-input:focus { 302 | outline: none; 303 | } 304 | 305 | @media (max-width: 1200px) { 306 | #profiles-modal #new-profile-input { 307 | width: 88%; 308 | } 309 | } 310 | 311 | #guide-modal h1 { 312 | text-align: center; 313 | } 314 | 315 | #guide-modal details { 316 | padding: 15px; 317 | } 318 | 319 | #guide-modal details summary { 320 | font-size: 20px; 321 | outline: none; 322 | border-bottom: 1px solid transparent; 323 | -webkit-transition : border .25s ease-in; 324 | -moz-transition : border .25s ease-in; 325 | -o-transition : border .25s ease-in; 326 | transition : border .25s ease-in; 327 | outline: none; 328 | } 329 | 330 | #guide-modal details > summary { 331 | list-style: none; 332 | } 333 | 334 | #guide-modal details > summary::-webkit-details-marker { 335 | display: none; 336 | } 337 | 338 | #guide-modal details summary:focus, 339 | #guide-modal details summary:hover { 340 | border-color: var(--tangerine); 341 | outline: none; 342 | } 343 | 344 | #guide-modal details summary div { 345 | padding-bottom: 10px; 346 | } 347 | 348 | #guide-modal details summary label { 349 | color: var(--dark-aluminium); 350 | font-size: 18px; 351 | float: right; 352 | } 353 | 354 | #guide-modal details summary img { 355 | display: none; 356 | } 357 | 358 | #guide-modal details summary::-webkit-details-marker, 359 | #guide-modal details summary::marker { 360 | display: none; 361 | font-size: 0; 362 | } 363 | 364 | #guide-modal details summary:hover { 365 | cursor: pointer; 366 | } 367 | 368 | #guide-modal details summary:focus { 369 | border-color: transparent; 370 | } 371 | 372 | #guide-modal details[open] p p ~ * { 373 | -webkit-animation: fadeIn 0.5s; 374 | animation: fadeIn 0.5s; 375 | } 376 | 377 | #guide-modal details[open] summary { 378 | border-bottom: 1px solid var(--tangerine); 379 | } 380 | 381 | #guide-modal details[open] summary div { 382 | font-size: 28px; 383 | text-align: center; 384 | } 385 | 386 | #guide-modal details[open] summary label { 387 | display: none; 388 | } 389 | 390 | #guide-modal details[open] summary img { 391 | display: block; 392 | height: 20px; 393 | width: 20px; 394 | float: left; 395 | position: relative; 396 | top: 10px; 397 | } 398 | 399 | #guide-modal details p, .items-container { 400 | text-align: center; 401 | font-size: 20px; 402 | } 403 | 404 | #guide-modal details p ul, 405 | .items-container ul { 406 | list-style-type: none; 407 | } 408 | 409 | #guide-modal details p ul li, 410 | .items-container ul li { 411 | padding: 10px; 412 | } 413 | 414 | hr { 415 | border: 0; 416 | height: 1px; 417 | border-top: 1px solid rgba(0, 0, 0, 0.1); 418 | border-bottom: 1px solid rgba(255, 255, 255, 0.3); 419 | } 420 | -------------------------------------------------------------------------------- /app/controllers/sail/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | class ApplicationController < ActionController::Base # :nodoc: 5 | protect_from_forgery with: :exception 6 | 7 | protected 8 | 9 | def current_user 10 | main_app.scope.request.env["warden"]&.user 11 | end 12 | 13 | def default_url_options 14 | { locale: I18n.locale } 15 | end 16 | 17 | def set_locale 18 | I18n.locale = params[:locale].presence || I18n.default_locale 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/controllers/sail/profiles_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_dependency "sail/application_controller" 4 | 5 | module Sail 6 | # ProfilesController 7 | # 8 | # This controller implements all profile related 9 | # APIs. 10 | class ProfilesController < ApplicationController 11 | def create 12 | respond_to do |format| 13 | format.js do 14 | @profile, @new_record = Sail::Profile.create_or_update_self(s_params[:name]) 15 | end 16 | end 17 | end 18 | 19 | def switch 20 | respond_to do |format| 21 | format.js { Sail::Profile.switch(s_params[:name]) } 22 | format.json { Sail::Profile.switch(s_params[:name]) } 23 | end 24 | end 25 | 26 | def destroy 27 | respond_to do |format| 28 | format.js do 29 | @profile = Sail::Profile.find_by(name: s_params[:name]).destroy 30 | end 31 | end 32 | end 33 | 34 | private 35 | 36 | def s_params 37 | params.permit(:name, :locale) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /app/controllers/sail/settings_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_dependency "sail/application_controller" 4 | 5 | module Sail 6 | # SettingsController 7 | # This is the main controller for settings 8 | # Implements all actions for the dashboard 9 | # and for the JSON API 10 | class SettingsController < ApplicationController 11 | before_action :set_locale, only: :index 12 | after_action :log_update, only: %i[update reset], if: -> { Sail.configuration.enable_logging && @successful_update } 13 | def index 14 | @settings = Setting.by_query(s_params[:query]).ordered_by(s_params[:order_field]) 15 | @number_of_pages = (@settings.count.to_f / Sail::ConstantCollection::SETTINGS_PER_PAGE).ceil 16 | @settings = @settings.paginated(s_params[:page], Sail::ConstantCollection::SETTINGS_PER_PAGE) 17 | fresh_when(@settings) 18 | end 19 | 20 | def update 21 | respond_to do |format| 22 | @setting, @successful_update = Setting.set(s_params[:name], s_params[:value]) 23 | format.js 24 | format.json { @successful_update ? head(:ok) : head(:conflict) } 25 | end 26 | end 27 | 28 | def show 29 | respond_to do |format| 30 | format.json do 31 | render json: { 32 | value: Sail::Setting.get(s_params[:name]) 33 | } 34 | end 35 | end 36 | end 37 | 38 | def switcher 39 | respond_to do |format| 40 | format.json do 41 | render json: { 42 | value: Sail::Setting.switcher(positive: s_params[:positive], 43 | negative: s_params[:negative], 44 | throttled_by: s_params[:throttled_by]) 45 | } 46 | rescue Sail::Setting::UnexpectedCastType 47 | head(:bad_request) 48 | rescue ActiveRecord::RecordNotFound 49 | head(:not_found) 50 | end 51 | end 52 | end 53 | 54 | def reset 55 | respond_to do |format| 56 | @setting, @successful_update = Setting.reset(s_params[:name]) 57 | format.js { render :update } 58 | end 59 | end 60 | 61 | private 62 | 63 | def s_params 64 | params.permit(:page, :query, :name, 65 | :value, :positive, :negative, 66 | :throttled_by, :order_field, 67 | :_method, :locale, :authenticity_token) 68 | end 69 | 70 | def log_update 71 | message = +"#{DateTime.now.utc.strftime("%Y/%m/%d %H:%M")} [Sail] #{action_name.capitalize} setting='#{@setting.name}' " \ 72 | "value='#{@setting.value}'" 73 | 74 | message << " author_user_id=#{current_user.id}" if current_user 75 | Rails.logger.info(message) 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /app/helpers/sail/application_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | module ApplicationHelper # :nodoc: 5 | def main_app 6 | Rails.application.class.routes.url_helpers 7 | end 8 | 9 | def formatted_date(setting) 10 | DateTime.parse(setting.value).utc.strftime(Sail::ConstantCollection::INPUT_DATE_FORMAT) 11 | end 12 | 13 | def settings_container_class(number_of_pages) 14 | number_of_pages.zero? ? "empty" : "" 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/models/sail/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | class ApplicationRecord < ActiveRecord::Base # :nodoc: 5 | self.abstract_class = true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/models/sail/entry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | # Entry 5 | # 6 | # The Entry class holds the saved value 7 | # for settings for each profile. It is 8 | # a model for representing the N x N 9 | # relationship between profiles and settings 10 | class Entry < ApplicationRecord 11 | belongs_to :setting 12 | belongs_to :profile 13 | validates :value, presence: true 14 | 15 | scope :by_profile_name, ->(name) { joins(:profile).where(sail_profiles: { name: name }) } 16 | 17 | delegate :name, to: :setting 18 | 19 | # dirty? 20 | # 21 | # dirty? will return true if 22 | # the entry value is different 23 | # than the associated setting value. 24 | # This happens when a setting is changed 25 | # without saving the profile. 26 | def dirty? 27 | value != setting.value 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/models/sail/profile.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | # Profile 5 | # 6 | # The profile model contains the 7 | # definitions for keeping a collection 8 | # of settings' values saved. It allows 9 | # switching between different collections. 10 | class Profile < ApplicationRecord 11 | has_many :entries, dependent: :destroy 12 | has_many :settings, through: :entries 13 | validates :name, presence: true, uniqueness: { case_sensitive: false } 14 | validate :only_one_active_profile 15 | 16 | # create_or_update_self 17 | # 18 | # Creates or updates a profile with name 19 | # +name+ saving the values of all settings 20 | # in the database. 21 | def self.create_or_update_self(name) 22 | profile = where(name: name).first_or_initialize 23 | new_record = profile.new_record? 24 | 25 | Sail::Setting.select(:id, :value).each do |setting| 26 | entry = setting.entries.where(profile: profile).first_or_initialize(profile: profile, setting: setting) 27 | entry.value = setting.value 28 | entry.save! 29 | end 30 | 31 | profile.save! 32 | handle_profile_activation(name) 33 | [profile, new_record] 34 | end 35 | 36 | # switch 37 | # 38 | # Switch to a different setting profile. Set the value 39 | # of every setting to what was previously saved. 40 | def self.switch(name) 41 | Sail::Entry.by_profile_name(name).each do |entry| 42 | Sail::Setting.set(entry.name, entry.value) 43 | end 44 | 45 | handle_profile_activation(name) 46 | end 47 | 48 | # handle_profile_activation 49 | # 50 | # Set other profiles to active false and 51 | # set the selected profile to active true. 52 | # rubocop:disable Rails/SkipsModelValidations 53 | def self.handle_profile_activation(name) 54 | select(:id, :name).where(active: true).update_all(active: false) 55 | select(:id, :name).where(name: name).update_all(active: true) 56 | end 57 | # rubocop:enable Rails/SkipsModelValidations 58 | 59 | # current 60 | # 61 | # Returns the currently activated profile 62 | def self.current 63 | find_by(active: true) 64 | end 65 | 66 | private_class_method :handle_profile_activation 67 | 68 | # dirty? 69 | # 70 | # A profile is considered dirty if it is active 71 | # but setting values have been changed and do 72 | # not match the entries definitions. 73 | def dirty? 74 | @dirty ||= entries.any?(&:dirty?) 75 | end 76 | 77 | private 78 | 79 | def only_one_active_profile 80 | if active? && Profile.where(active: true).where.not(id: id).count.positive? 81 | errors.add(:single_active_profile, "Cannot have two active profiles at once") 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /app/models/sail/setting.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "fugit" 4 | 5 | module Sail 6 | # Setting 7 | # This is the model used for settings 8 | # it contains all data definitions, 9 | # validations, scopes and methods 10 | class Setting < ApplicationRecord 11 | class UnexpectedCastType < StandardError; end 12 | 13 | FULL_RANGE = (0...100).freeze 14 | AVAILABLE_MODELS = Dir[Rails.root.join("app/models/*.rb")] 15 | .map { |dir| dir.split("/").last.camelize.gsub(".rb", "") }.freeze 16 | 17 | has_many :entries, dependent: :destroy 18 | attr_reader :caster 19 | 20 | validates :value, :cast_type, presence: true 21 | validates :name, presence: true, uniqueness: { case_sensitive: false } 22 | enum cast_type: %i[integer string boolean range array float 23 | ab_test cron obj_model date uri throttle 24 | locales set].freeze 25 | 26 | validate :value_is_within_range, if: -> { range? } 27 | validate :value_is_true_or_false, if: -> { boolean? || ab_test? } 28 | validate :cron_is_valid, if: -> { cron? } 29 | validate :model_exists, if: -> { obj_model? } 30 | validate :date_is_valid, if: -> { date? } 31 | validate :uri_is_valid, if: -> { uri? } 32 | 33 | after_initialize :instantiate_caster 34 | 35 | scope :paginated, ->(page, per_page) { offset(page.to_i * per_page).limit(per_page) } 36 | 37 | scope :by_query, lambda { |query| 38 | if cast_types.key?(query) || query == Sail::ConstantCollection::STALE 39 | send(query) 40 | elsif select(:id).by_group(query).exists? 41 | by_group(query) 42 | elsif query.to_s.include?(Sail::ConstantCollection::RECENT) 43 | recently_updated(query.delete("recent ").strip.to_i) 44 | else 45 | by_name(query) 46 | end 47 | } 48 | 49 | scope :by_group, ->(group) { where(group: group) } 50 | scope :by_name, ->(name) { name.present? ? where("name LIKE ?", "%#{name}%") : all } 51 | scope :stale, -> { where("updated_at < ?", Sail.configuration.days_until_stale.days.ago) } 52 | scope :recently_updated, ->(amount) { where("updated_at >= ?", amount.to_i.hours.ago) } 53 | scope :ordered_by, ->(field) { column_names.include?(field) ? order("#{field}": :desc) : all } 54 | scope :for_value_by_name, ->(name) { select(:value, :cast_type).where(name: name) } 55 | 56 | def self.get(name) 57 | Sail.instrumenter.increment_usage_of(name) 58 | 59 | cached_setting = Rails.cache.read("setting_get_#{name}") 60 | return cached_setting unless cached_setting.nil? 61 | 62 | setting = Setting.for_value_by_name(name).first 63 | return if setting.nil? 64 | 65 | setting_value = setting.safe_cast 66 | 67 | unless setting.should_not_cache? 68 | Rails.cache.write( 69 | "setting_get_#{name}", setting_value, 70 | expires_in: Sail.configuration.cache_life_span 71 | ) 72 | end 73 | 74 | setting_value 75 | end 76 | 77 | def self.set(name, value) 78 | setting = Setting.find_by(name: name) 79 | value_cast = setting.caster.from(value) 80 | success = setting.update(value: value_cast) 81 | Rails.cache.delete("setting_get_#{name}") if success 82 | [setting, success] 83 | end 84 | 85 | def self.switcher(positive:, negative:, throttled_by:) 86 | setting = select(:cast_type).find_by(name: throttled_by) 87 | raise ActiveRecord::RecordNotFound, "Can't find throttle setting" if setting.nil? 88 | 89 | raise UnexpectedCastType unless setting.throttle? 90 | 91 | get(throttled_by) ? get(positive) : get(negative) 92 | end 93 | 94 | def self.reset(name) 95 | if File.exist?(config_file_path) 96 | defaults = YAML.load_file(config_file_path) 97 | set(name, defaults[name]["value"]) 98 | end 99 | end 100 | 101 | def self.load_defaults(override = false) 102 | if File.exist?(config_file_path) && 103 | ActiveRecord::Base.connection.table_exists?(table_name) 104 | 105 | destroy_all if override 106 | config = YAML.load_file(config_file_path) 107 | find_or_create_settings(config) 108 | destroy_missing_settings(config.keys) 109 | end 110 | end 111 | 112 | def self.config_file_path 113 | Sail::ConstantCollection::CONFIG_FILE_PATH 114 | end 115 | 116 | def self.destroy_missing_settings(keys) 117 | deleted_settings = pluck(:name) - keys 118 | where(name: deleted_settings).destroy_all 119 | end 120 | 121 | def self.find_or_create_settings(config) 122 | config.each do |name, attrs| 123 | string_attrs = attrs.merge(name: name) 124 | string_attrs.update(string_attrs) { |_, v| v.to_s } 125 | where(name: name).first_or_create(string_attrs) 126 | end 127 | end 128 | 129 | def self.database_to_file 130 | attributes = {} 131 | 132 | Setting.all.find_each do |setting| 133 | setting_attrs = setting.attributes.except("id", "name", "created_at", "updated_at", "cast_type") 134 | attributes[setting.name] = setting_attrs.merge("cast_type" => setting.cast_type) 135 | end 136 | 137 | File.write(config_file_path, attributes.to_yaml) 138 | end 139 | 140 | private_class_method :config_file_path, :destroy_missing_settings, 141 | :find_or_create_settings 142 | 143 | def display_name 144 | name.gsub(/[^a-zA-Z\d]/, " ").titleize 145 | end 146 | 147 | def stale? 148 | return if Sail.configuration.days_until_stale.blank? 149 | 150 | updated_at < Sail.configuration.days_until_stale.days.ago 151 | end 152 | 153 | def relevancy 154 | Sail.instrumenter.relevancy_of(name) 155 | end 156 | 157 | def should_not_cache? 158 | ab_test? || cron? || throttle? 159 | end 160 | 161 | def safe_cast 162 | try(:caster).try(:to_value) 163 | end 164 | 165 | # cache_index 166 | # 167 | # Used in _setting.html.erb for the cache_key 168 | def cache_index 169 | Sail.instrumenter[name][:usages] / Instrumenter::USAGES_UNTIL_CACHE_EXPIRE 170 | end 171 | 172 | private 173 | 174 | def instantiate_caster 175 | return unless has_attribute?(:cast_type) 176 | 177 | @caster = "Sail::Types::#{cast_type.camelize}" 178 | .constantize 179 | .new(self) 180 | end 181 | 182 | def model_exists 183 | errors.add(:invalid_model, "Model does not exist") unless AVAILABLE_MODELS.include?(value) 184 | end 185 | 186 | def value_is_true_or_false 187 | if Sail::ConstantCollection::STRING_BOOLEANS.exclude?(value) 188 | errors.add(:not_a_boolean_error, 189 | "Boolean settings only take values inside #{Sail::ConstantCollection::STRING_BOOLEANS}") 190 | end 191 | end 192 | 193 | def value_is_within_range 194 | unless FULL_RANGE.cover?(caster.to_value) 195 | errors.add(:outside_range_error, 196 | "Range settings only take values inside range #{FULL_RANGE}") 197 | end 198 | end 199 | 200 | def date_is_valid 201 | DateTime.parse(value).utc 202 | rescue ArgumentError 203 | errors.add(:invalid_date, "Date format is invalid") 204 | end 205 | 206 | def cron_is_valid 207 | if Fugit::Cron.new(value).nil? 208 | errors.add(:invalid_cron_string, 209 | "Setting value is not a valid cron") 210 | end 211 | end 212 | 213 | def uri_is_valid 214 | URI(value) 215 | rescue URI::InvalidURIError 216 | errors.add(:invalid_uri, "URI value is invalid") 217 | end 218 | end 219 | end 220 | -------------------------------------------------------------------------------- /app/views/layouts/sail/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= I18n.t('sail.page_title') %> 9 | 10 | <%= stylesheet_link_tag "sail/application", media: "all" %> 11 | <%= javascript_include_tag "sail/application", async: true %> 12 | <%= csrf_meta_tags %> 13 | 14 | 15 |
16 | 25 | 26 |
27 | <%= yield %> 28 | 29 | <%= hidden_field_tag(:auto_search_enabled, Sail.configuration.enable_search_auto_submit) %> 30 |
31 | 32 | 33 | -------------------------------------------------------------------------------- /app/views/sail/profiles/_profile.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | <% if profile.active? %> 4 | " 5 | title="<%= profile.dirty? ? I18n.t("sail.dirty_profile_tooltip") : I18n.t("sail.clean_profile_tooltip") %>"> 6 | ⬤ 7 | 8 | 9 | <% unless Sail.instrumenter.profile(profile.name).zero? %> 10 | "> 11 | <%= I18n.t("sail.profile_errors", count: Sail.instrumenter.profile(profile.name)) %> 12 | 13 | <% end %> 14 | <% end %> 15 |
16 | 17 |
18 | <%= profile.name.titleize %> 19 |
20 | 21 |
22 |
23 |
24 | <%= form_tag(profile_path(name: profile.name), method: :delete, remote: true) do %> 25 | 26 | <% end %> 27 |
28 | 29 |
30 | <%= form_tag(switch_profile_path(name: profile.name), method: :put, remote: true) do %> 31 | 32 | <% end %> 33 |
34 | 35 |
36 | <%= form_tag(profiles_path, method: :post, remote: true) do %> 37 | 38 | 39 | <% end %> 40 |
41 |
42 |
43 |
44 | -------------------------------------------------------------------------------- /app/views/sail/profiles/create.js.erb: -------------------------------------------------------------------------------- 1 | window.messageContainer = document.getElementById("response-message"); 2 | window.profilesContainer = document.getElementById("profiles-container"); 3 | window.indicator = document.getElementsByClassName("active-indicator")[0]; 4 | 5 | window.messageContainer.innerText = "<%= @new_record ? I18n.t("sail.profile_created") : I18n.t("sail.profile_updated") %>"; 6 | 7 | <% if @new_record %> 8 | window.profilesContainer.insertAdjacentHTML('beforeend', '<%= j render(partial: "sail/profiles/profile", locals: { profile: @profile }) %>'); 9 | <% elsif @profile.active? %> 10 | window.indicator.classList.remove("yellow"); 11 | window.indicator.classList.add("green"); 12 | <% end %> 13 | 14 | setTimeout(function () { 15 | window.messageContainer.innerText = ""; 16 | }, 1500); 17 | -------------------------------------------------------------------------------- /app/views/sail/profiles/destroy.js.erb: -------------------------------------------------------------------------------- 1 | window.removedProfile = document.getElementById("<%= @profile.name %>"); 2 | window.messageContainer = document.getElementById("response-message"); 3 | window.profilesContainer = document.getElementById("profiles-container"); 4 | 5 | window.messageContainer.innerText = "<%= I18n.t("sail.profile_deleted") %>"; 6 | window.profilesContainer.removeChild(window.removedProfile); 7 | 8 | setTimeout(function () { 9 | window.messageContainer.innerText = ""; 10 | }, 1500); 11 | -------------------------------------------------------------------------------- /app/views/sail/profiles/switch.js.erb: -------------------------------------------------------------------------------- 1 | window.messageContainer = document.getElementById("response-message"); 2 | window.messageContainer.innerText = "<%= I18n.t("sail.profile_switching") %>"; 3 | 4 | setTimeout(function () { 5 | location.reload(); 6 | }, 1500); 7 | -------------------------------------------------------------------------------- /app/views/sail/settings/_guide_modal.html.erb: -------------------------------------------------------------------------------- 1 | 80 | -------------------------------------------------------------------------------- /app/views/sail/settings/_profiles_modal.html.erb: -------------------------------------------------------------------------------- 1 | 25 | -------------------------------------------------------------------------------- /app/views/sail/settings/_search.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | <%= form_tag(settings_path, method: :get, id: "search-form") do %> 6 | " 8 | title="<%= I18n.t("sail.search_tooltip") %>"> 9 | <% end %> 10 | 11 |
12 | 13 |
14 |
15 | 16 | 19 | <%= render(partial: "sort_menu") %> 20 | 21 | 24 | 25 | <% if main_app.respond_to?(Sail.configuration.back_link_path.to_s) %> 26 | <%= link_to(main_app.method(Sail.configuration.back_link_path).call, method: :get, id: "main-app-link", class: "search-button", title: I18n.t("sail.main_app")) do %> 27 | <%= image_tag("sail/external-link-alt.svg", alt: I18n.t("sail.main_app")) %> 28 | <% end %> 29 | <% end %> 30 | 31 |
32 |
33 | -------------------------------------------------------------------------------- /app/views/sail/settings/_setting.html.erb: -------------------------------------------------------------------------------- 1 | <% cache [setting, setting.cache_index], expires_in: Sail.configuration.cache_life_span do %> 2 |
3 |
4 |

<%= setting.display_name %>

5 |
6 | 7 |
8 |
9 |
10 |
11 | <%= form_tag(reset_setting_path(name: setting.name), method: :put, remote: true) do %> 12 | 15 | <% end %> 16 |
17 | 18 |
19 | "><%= setting.relevancy %> 20 |
21 |
22 |
23 | 24 |
25 |
26 |
27 | <%= link_to(setting.cast_type, settings_path(query: setting.cast_type), method: :get, class: "tag type-label") %> 28 |
29 | 30 | <% if setting.group.present? %> 31 |
32 | <%= link_to(setting.group, settings_path(query: setting.group), method: :get, class: "tag group-label") %> 33 |
34 | <% end %> 35 | 36 | <% if setting.stale? %> 37 |
38 | <%= link_to(I18n.t("sail.stale"), settings_path(query: Sail::ConstantCollection::STALE), method: :get, class: "tag stale-label", title: I18n.t("sail.stale_tooltip", days: Sail.configuration.days_until_stale)) %> 39 |
40 | <% end %> 41 |
42 |
43 | 44 |
45 | <%= form_tag(setting_path(name: setting.name), method: :put, remote: true) do %> 46 |
47 | <% if setting.boolean? || setting.ab_test? %> 48 |
49 | 53 |
54 | <% elsif setting.range? %> 55 |
56 | " type="range" min="0" max="99" value="<%= setting.value %>" name="value" class="value-slider"> 57 |
58 | <% else %> 59 |
60 | " type="text" name="value" class="value-input" value="<%= setting.value %>"/> 61 |
62 | <% end %> 63 | 64 |
65 | 66 | 67 | 68 | <%= image_tag("sail/check.svg") %> 69 | 70 | 71 | 72 | <%= image_tag("sail/times.svg") %> 73 | 74 |
75 |
76 | <% end %> 77 |
78 |
79 |
80 | 81 |
82 |

<%= setting.display_name %>

83 |
84 | 85 |

86 | 87 |

88 |
89 |
90 | <% end %> 91 | -------------------------------------------------------------------------------- /app/views/sail/settings/_sort_menu.html.erb: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /app/views/sail/settings/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <% cache @settings do %> 3 | <%= render(partial: "search") %> 4 | 5 |
6 | <% if @number_of_pages > 0 %> 7 | <%= render(partial: "setting", collection: @settings) %> 8 | <% else %> 9 |

<%= I18n.t("sail.no_settings") %>

10 | <% end %> 11 | 12 |
13 |
14 | 15 | 41 | <% end %> 42 |
43 | 44 | <%= render(partial: "profiles_modal") %> 45 | <%= render(partial: "guide_modal", locals: { settings: @settings }) %> 46 | -------------------------------------------------------------------------------- /app/views/sail/settings/update.js.erb: -------------------------------------------------------------------------------- 1 | var notice = document.getElementById("<%= @successful_update ? "success-#{@setting.name}" : "alert-#{@setting.name}" %>"); 2 | var submit = document.getElementById("btn-submit-<%= @setting.name %>"); 3 | 4 | submit.style.display = "none"; 5 | notice.style.display = "inline-block"; 6 | 7 | setTimeout(function () { 8 | notice.style.display = "none"; 9 | submit.style.display = "inline-block"; 10 | }, 1500); 11 | 12 | if ("<%= @successful_update %>" === "true") { 13 | var input = document.getElementById("<%= "input_for_#{@setting.name}" %>"); 14 | var submitButton = document.getElementById("<%= "btn-submit-#{@setting.name}" %>"); 15 | 16 | if ("<%= @setting.boolean? %>" === "true") { 17 | input.checked = "<%= @setting.value %>" === "true"; 18 | initialSettingValues["<%= @setting.name %>"] = "<%= @setting.value %>" === "true"; 19 | } else { 20 | input.value = "<%= @setting.date? ? formatted_date(@setting) : @setting.value %>"; 21 | initialSettingValues["<%= @setting.name %>"] = "<%= @setting.date? ? formatted_date(@setting) : @setting.value %>"; 22 | } 23 | 24 | submitButton.classList.remove("orange"); 25 | submitButton.disabled = true; 26 | } 27 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # This command will automatically be run when you run "rails" with Rails gems 5 | # installed from the root of your application. 6 | 7 | ENGINE_ROOT = File.expand_path("..", __dir__) 8 | ENGINE_PATH = File.expand_path("../lib/sail/engine", __dir__) 9 | APP_PATH = File.expand_path("../spec/dummy/config/application", __dir__) 10 | 11 | # Set up gems listed in the Gemfile. 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 13 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 14 | 15 | require "rails/all" 16 | require "rails/engine/commands" 17 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | sail: 3 | page_title: Sail dashboard 4 | save: SAVE 5 | activate: ACTIVATE 6 | delete: DELETE 7 | search_placeholder: Setting name, group, cast type, stale or recent x 8 | search_tooltip: "When searching for recently updated settings, x is the number of hours since the update (e.g.: recent 2)" 9 | main_app: Main app 10 | no_settings: No settings found 11 | refresh_tooltip: Reset setting value 12 | next_page: Next page 13 | previous_page: Previous page 14 | stale: stale 15 | stale_tooltip: This setting has not been updated in %{days} days. Consider removing it and refactoring the code. 16 | order_button_tooltip: Sort settings in descending order by the selected field. 17 | relevancy_tooltip: The relevancy score is a relative indicator of how critical for the application each setting is. 18 | profiles_tooltip: Edit application profiles 19 | profile_created: Created 20 | profile_updated: Saved 21 | profile_switching: Switching.. 22 | profile_deleted: Deleted 23 | profiles: Profiles 24 | new_profile_tooltip: "New profile: save a current snapshot of your settings" 25 | clean_profile_tooltip: Active profile. All changes saved. 26 | dirty_profile_tooltip: Active profile. Recent setting changes have not been saved. 27 | guide: Guide 28 | searching: Searching 29 | by_setting_name_html: By the setting name: will look for partial matches 30 | by_group_html: "By group: will find all settings in the same group (must be exact match)" 31 | by_cast_type_html: "By cast type: will find all settings with the same type (must be exact match)" 32 | by_stale_html: "By stale: will find settings that are stale (haven't been updated recently)" 33 | by_recent_html: "By recent: will find settings updated in the last X hours (e.g.: recent 50)" 34 | click_title: Click a setting's title to view its description. 35 | profiles_can_be_used: Profiles can be used to configure many states of settings. They save the values of all settings in a given moment. 36 | profile_configuring: Configure settings as desired and create a new profile. Activate profiles to change the value of all settings at once. 37 | relevancy_score: Relevancy Score 38 | relevancy_score_explanation: Settings have a number on the top right portion indicating their relevancy score. This metric is calculated based on the relative number of times the setting is used while the application is running. The higher the value the more the setting is used. 39 | available_groups_and_types: Available groups and types 40 | groups_are: "The groups currently used are:" 41 | types_are: "The cast types currently used are:" 42 | how_to_find_settings: How to find settings you are looking for 43 | how_to_profiles: How to organize your settings in profiles 44 | how_to_relevancy_score: What is the relevancy score and how to use it 45 | how_to_groups_and_types: List of available groups and types 46 | profile_errors: 47 | one: 1 error 48 | other: "%{count} errors" 49 | profile_error_tooltip: This is the number of unexpected errors raised for all settings while this profile was active 50 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Sail::Engine.routes.draw do 4 | root "settings#index" 5 | 6 | resources :settings, only: %i[index update show], param: :name do 7 | member do 8 | put "reset" 9 | end 10 | end 11 | 12 | get "settings/switcher/:positive/:negative/:throttled_by" => "settings#switcher" 13 | 14 | resources :profiles, only: %i[create destroy], param: :name do 15 | member do 16 | put "switch" 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/false_class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Patch for Ruby's FalseClass 4 | class FalseClass 5 | def to_s 6 | Sail::ConstantCollection::FALSE 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/generators/sail/install/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators/migration" 4 | 5 | module Sail 6 | module Generators 7 | # InstallGenerator 8 | # This is the install generator for Sail 9 | # which creates the necessary migrations 10 | class InstallGenerator < ::Rails::Generators::Base 11 | include Rails::Generators::Migration 12 | 13 | source_root File.expand_path("templates", __dir__) 14 | desc "Create Sail migrations" 15 | 16 | def self.next_migration_number(_path) 17 | if @prev_migration_nr 18 | @prev_migration_nr += 1 19 | else 20 | @prev_migration_nr = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i 21 | end 22 | 23 | @prev_migration_nr.to_s 24 | end 25 | 26 | def copy_migrations 27 | migration_template "create_sail_settings.rb", 28 | "db/migrate/create_sail_settings.rb", 29 | migration_version: migration_version 30 | 31 | migration_template "create_sail_profiles.rb", 32 | "db/migrate/create_sail_profiles.rb", 33 | migration_version: migration_version 34 | end 35 | 36 | def create_config_file 37 | template "sail.yml", "config/sail.yml" 38 | end 39 | 40 | def migration_version 41 | "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/generators/sail/install/templates/create_sail_profiles.rb: -------------------------------------------------------------------------------- 1 | class CreateSailProfiles < ActiveRecord::Migration<%= migration_version %> 2 | def change 3 | create_table :sail_entries do |t| 4 | t.string :value, null: false 5 | t.references :setting, index: true 6 | t.references :profile, index: true 7 | t.timestamps 8 | end 9 | 10 | create_table :sail_profiles do |t| 11 | t.string :name, null: false 12 | t.boolean :active, default: false 13 | t.index ["name"], name: "index_sail_profiles_on_name", unique: true 14 | t.timestamps 15 | end 16 | 17 | add_foreign_key(:sail_entries, :sail_settings, column: :setting_id) 18 | add_foreign_key(:sail_entries, :sail_profiles, column: :profile_id) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/generators/sail/install/templates/create_sail_settings.rb: -------------------------------------------------------------------------------- 1 | class CreateSailSettings < ActiveRecord::Migration<%= migration_version %> 2 | def change 3 | create_table :sail_settings do |t| 4 | t.string :name, null: false 5 | t.text :description 6 | t.string :value, null: false 7 | t.string :group 8 | t.integer :cast_type, null: false, limit: 1 9 | t.timestamps 10 | t.index ["name"], name: "index_settings_on_name", unique: true 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/generators/sail/install/templates/sail.yml.tt: -------------------------------------------------------------------------------- 1 | name_of_setting: 2 | description: Describe what the setting does 3 | value: 'true' 4 | cast_type: boolean 5 | group: feature_flags 6 | -------------------------------------------------------------------------------- /lib/generators/sail/update/templates/add_group_to_sail_settings.rb: -------------------------------------------------------------------------------- 1 | class AddGroupToSailSettings < ActiveRecord::Migration<%= migration_version %> 2 | def change 3 | add_column(:sail_settings, :group, :string) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/generators/sail/update/update_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators/migration" 4 | 5 | module Sail 6 | module Generators 7 | # UpdateGenerator 8 | # 9 | # The UpdateGenerator analyzes the current 10 | # state of the database and helps users 11 | # upgrade to the latest Sail version. 12 | class UpdateGenerator < ::Rails::Generators::Base 13 | include Rails::Generators::Migration 14 | 15 | source_root File.expand_path("templates", __dir__) 16 | desc "Update an application to Sail's latest version" 17 | 18 | def self.next_migration_number(_path) 19 | if @prev_migration_nr 20 | @prev_migration_nr += 1 21 | else 22 | @prev_migration_nr = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i 23 | end 24 | 25 | @prev_migration_nr.to_s 26 | end 27 | 28 | def copy_migrations 29 | # Add migration to add group to settings 30 | # if upgrading from Sail <= 1.x.x 31 | 32 | if Sail::Setting.column_names.exclude?("group") 33 | migration_template "add_group_to_sail_settings.rb", 34 | "db/migrate/add_group_to_sail_settings.rb", 35 | migration_version: migration_version 36 | end 37 | 38 | # Add migration to create profiles 39 | # if upgrading from Sail <= 2.x.x 40 | 41 | unless ActiveRecord::Base.connection.table_exists?("sail_profiles") 42 | migration_template "#{__dir__}/../install/templates/create_sail_profiles.rb", 43 | "db/migrate/create_sail_profiles.rb", 44 | migration_version: migration_version 45 | end 46 | end 47 | 48 | def migration_version 49 | "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/generators/sail/views/views_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | module Generators 5 | # ViewsGenerator 6 | # Copies the customizable views to the main app 7 | class ViewsGenerator < ::Rails::Generators::Base 8 | source_root File.expand_path("../../../../app/views/sail", __dir__) 9 | desc "Copies customizable views to the parent application." 10 | 11 | def copy_views 12 | copy_file("settings/_setting.html.erb", "app/views/sail/settings/_setting.html.erb") 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/sail.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "sail/engine" 4 | require "true_class" 5 | require "false_class" 6 | 7 | module Sail # :nodoc: 8 | autoload :ConstantCollection, "sail/constant_collection" 9 | autoload :Configuration, "sail/configuration" 10 | autoload :Instrumenter, "sail/instrumenter" 11 | autoload :Types, "sail/types" 12 | autoload :Graphql, "sail/graphql" 13 | 14 | class << self 15 | attr_writer :configuration 16 | 17 | # Gets the value of a setting casted with the 18 | # appropriate type. Can be used with a block. 19 | # 20 | # Response is cached until the setting's value 21 | # is updated or until the time specific in 22 | # the configuration expires. 23 | # 24 | # Examples: 25 | # 26 | # Sail.get("my_setting") 27 | # => true 28 | # 29 | # Sail.get("my_setting") do |setting_value| 30 | # execute_code if setting_value 31 | # end 32 | def get(name, expected_errors: []) 33 | setting_value = Sail::Setting.get(name) 34 | 35 | block_given? ? yield(setting_value) : setting_value 36 | rescue StandardError => e 37 | instrumenter.increment_failure_of(name) unless expected_errors.blank? || expected_errors.include?(e.class) 38 | raise e 39 | end 40 | 41 | # Sets the value of a setting 42 | # 43 | # Updating a setting's value will cause its 44 | # cache to expire. 45 | # 46 | # Passed values are cast to string before 47 | # saving to the database. For instance, 48 | # the statement below will appropriately 49 | # update the setting value to "true". 50 | # 51 | # Sail.set(:boolean_setting, true) 52 | # 53 | def set(name, value) 54 | Sail::Setting.set(name, value) 55 | end 56 | 57 | # Resets the value of a setting 58 | # 59 | # Restores the original value defined 60 | # in config/sail.yml 61 | def reset(name) 62 | Sail::Setting.reset(name) 63 | end 64 | 65 | # Switches between the value of two settings randomly 66 | # 67 | # +throttled_by+: a throttle type setting 68 | # +positive+: a setting to be returned when the throttle returns true 69 | # +negative+: a setting to be returned when the throttle returns false 70 | # 71 | # Based on the +throttled_by+ setting, this method will 72 | # return either the value of +positive+ or +negative+. 73 | # 74 | # If +throttled_by+ returns true, the casted value of +positive+ 75 | # is returned. When false, the casted value of +negative+ is returned. 76 | def switcher(positive:, negative:, throttled_by:) 77 | Sail::Setting.switcher(positive: positive, negative: negative, throttled_by: throttled_by) 78 | end 79 | 80 | def configuration 81 | @configuration ||= Configuration.new 82 | end 83 | 84 | def configure 85 | yield(configuration) 86 | end 87 | 88 | def instrumenter 89 | @instrumenter ||= Instrumenter.new 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/sail/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | # Configuration 5 | # This class keeps the configuration 6 | # data for the gem. 7 | # Defaults be found here and can be 8 | # overridden in an initializer, environment 9 | # file or application.rb 10 | class Configuration 11 | attr_accessor :cache_life_span, :array_separator, :dashboard_auth_lambda, 12 | :back_link_path, :enable_search_auto_submit, :days_until_stale, 13 | :enable_logging, :failures_until_reset 14 | 15 | def initialize 16 | @cache_life_span = 6.hours 17 | @array_separator = ";" 18 | @dashboard_auth_lambda = nil 19 | @back_link_path = "root_path" 20 | @enable_search_auto_submit = true 21 | @days_until_stale = 60 22 | @enable_logging = true 23 | @failures_until_reset = 50 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/sail/constant_collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | # ConstantCollection 5 | # 6 | # This module includes a variety of 7 | # constants that are used multiple times 8 | # in the code to avoid unnecessary allocations. 9 | module ConstantCollection 10 | TRUE = "true" 11 | FALSE = "false" 12 | STRING_BOOLEANS = %w[true false].freeze 13 | BOOLEAN = "boolean" 14 | ON = "on" 15 | BOOLEANS = [true, false].freeze 16 | CONFIG_FILE_PATH = "./config/sail.yml" 17 | STALE = "stale" 18 | RECENT = "recent" 19 | FIELDS_FOR_SORT = %w[name updated_at cast_type group].freeze 20 | SETTINGS_PER_PAGE = 20 21 | INPUT_DATE_FORMAT = "%Y-%m-%dT%H:%m:%S" 22 | MAX_PAGES = 5 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/sail/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | # Engine 5 | # Defines initializers and 6 | # after initialize hooks 7 | class Engine < ::Rails::Engine 8 | require "sprockets/railtie" 9 | isolate_namespace Sail 10 | 11 | config.generators do |g| 12 | g.test_framework :rspec 13 | end 14 | 15 | config.middleware.use ActionDispatch::Flash 16 | config.middleware.use ActionDispatch::Cookies 17 | config.middleware.use ActionDispatch::ContentSecurityPolicy::Middleware if defined?(ActionDispatch::ContentSecurityPolicy) 18 | config.middleware.use Rack::MethodOverride 19 | config.middleware.use Rails::Rack::Logger 20 | config.middleware.use Rack::Head 21 | config.middleware.use Rack::ConditionalGet 22 | config.middleware.use Rack::ETag 23 | 24 | initializer "sail.assets.precompile" do |app| 25 | app.config.assets.precompile += %w[sail/undo.svg sail/sliders-h.svg sail/angle-left.svg 26 | sail/angle-right.svg sail/external-link-alt.svg sail/cog.svg sail/check.svg 27 | sail/times.svg sail/application.css sail/application.js] 28 | end 29 | 30 | initializer "sail" do 31 | unless Sail.configuration.dashboard_auth_lambda.nil? 32 | ActiveSupport::Reloader.to_prepare do 33 | Sail::SettingsController.before_action(Sail.configuration.dashboard_auth_lambda) 34 | end 35 | end 36 | end 37 | 38 | config.after_initialize do 39 | errors = [ActiveRecord::NoDatabaseError] 40 | errors << PG::ConnectionBad if defined?(PG) 41 | 42 | config.middleware.use Rails.application.config.session_store || ActionDispatch::Session::CookieStore 43 | 44 | begin 45 | Sail::Setting.load_defaults unless Rails.env.test? 46 | rescue *errors 47 | warn "Skipping setting creation because database doesn't exist" 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/sail/graphql.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # :nocov: 4 | module Sail 5 | # Graphql 6 | # 7 | # Module to include type definitions 8 | # for GraphQL APIs. 9 | module Graphql 10 | autoload :Mutations, "sail/mutations" 11 | 12 | module Types # :nodoc: 13 | extend ActiveSupport::Concern 14 | 15 | included do 16 | field :sail_get, ::GraphQL::Types::JSON, null: true do 17 | description "Returns the value for a given setting." 18 | argument :name, String, required: true, description: "The setting's name." 19 | end 20 | 21 | field :sail_switcher, ::GraphQL::Types::JSON, null: true do 22 | description "Switches between the positive or negative setting based on the throttle." 23 | argument :positive, String, required: true, description: "The setting's name if the throttle is bigger than the desired amount." 24 | argument :negative, String, required: true, description: "The setting's name if the throttle is smaller than the desired amount." 25 | argument :throttled_by, String, required: true, description: "The throttle setting's name." 26 | end 27 | 28 | def sail_get(name:) 29 | Sail.get(name) 30 | end 31 | 32 | def sail_switcher(positive:, negative:, throttled_by:) 33 | Sail.switcher( 34 | positive: positive, 35 | negative: negative, 36 | throttled_by: throttled_by 37 | ) 38 | end 39 | end 40 | end 41 | end 42 | end 43 | # :nocov: 44 | -------------------------------------------------------------------------------- /lib/sail/instrumenter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | # Instrumenter 5 | # 6 | # Class containing methods to instrument 7 | # setting usage and provide insights to 8 | # dashboard users. 9 | class Instrumenter 10 | USAGES_UNTIL_CACHE_EXPIRE = 500 11 | 12 | # initialize 13 | # 14 | # Declare basic hash containing setting 15 | # statistics 16 | def initialize 17 | @statistics = { settings: {}, profiles: {} }.with_indifferent_access 18 | @number_of_settings = Setting.count 19 | end 20 | 21 | # [] 22 | # 23 | # Accessor method for setting statistics to guarantee 24 | # proper initialization of hashes. 25 | def [](name) 26 | @statistics[:settings][name] = { usages: 0, failures: 0 }.with_indifferent_access if @statistics[:settings][name].blank? 27 | @statistics[:settings][name] 28 | end 29 | 30 | # increment_profile_failure_of 31 | # 32 | # Increments the number of failures 33 | # for settings while a profile is active 34 | def increment_profile_failure_of(name) 35 | @statistics[:profiles][name] ||= 0 36 | @statistics[:profiles][name] += 1 37 | end 38 | 39 | # profile 40 | # 41 | # Profile statistics accessor 42 | def profile(name) 43 | @statistics[:profiles][name] ||= 0 44 | end 45 | 46 | # increment_usage 47 | # 48 | # Simply increments the number of 49 | # times a setting has been called 50 | def increment_usage_of(setting_name) 51 | self[setting_name][:usages] += 1 52 | end 53 | 54 | # relative_usage_of 55 | # 56 | # Calculates the relative usage of 57 | # a setting compared to all others 58 | # in percentage 59 | def relative_usage_of(setting_name) 60 | return 0.0 if @statistics[:settings].empty? 61 | 62 | total_usages = @statistics[:settings].sum { |_, entry| entry[:usages] } 63 | return 0.0 if total_usages.zero? 64 | 65 | (100.0 * self[setting_name][:usages]) / total_usages 66 | end 67 | 68 | # increment_failure_of 69 | # 70 | # Counts the number of failed code block executions 71 | # enveloped by a given setting. If the number of failures 72 | # exceeds the amount configured, resets the setting value 73 | def increment_failure_of(setting_name) 74 | self[setting_name][:failures] += 1 75 | 76 | current_profile = Profile.current 77 | increment_profile_failure_of(current_profile.name) if current_profile 78 | 79 | Sail.reset(setting_name) if self[setting_name][:failures] > Sail.configuration.failures_until_reset 80 | end 81 | 82 | def relevancy_of(setting_name) 83 | (relative_usage_of(setting_name) / @number_of_settings).round(1) 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/sail/mutations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # :nocov: 4 | module Sail 5 | module Graphql 6 | module Mutations # :nodoc: 7 | extend ActiveSupport::Concern 8 | 9 | included do 10 | field :sail_set, mutation: SailSet do 11 | description "Set the value for a setting." 12 | argument :name, String, required: true 13 | argument :value, String, required: true 14 | end 15 | 16 | field :sail_profile_switch, mutation: SailProfileSwitch do 17 | description "Switches to the chosen profile." 18 | argument :name, String, required: true 19 | end 20 | end 21 | 22 | class SailSet < ::GraphQL::Schema::Mutation # :nodoc: 23 | argument :name, String, required: true 24 | argument :value, String, required: true 25 | 26 | field :success, Boolean, null: false 27 | 28 | def resolve(name:, value:) 29 | _, success = Sail.set(name, value) 30 | { success: success } 31 | end 32 | end 33 | 34 | class SailProfileSwitch < ::GraphQL::Schema::Mutation # :nodoc: 35 | argument :name, String, required: true 36 | 37 | field :success, Boolean, null: false 38 | 39 | def resolve(name:) 40 | success = Profile.exists?(name: name) 41 | Profile.switch(name) 42 | 43 | { success: success } 44 | end 45 | end 46 | end 47 | end 48 | end 49 | # :nocov: 50 | -------------------------------------------------------------------------------- /lib/sail/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | # Load custom rake tasks in main 5 | # application 6 | class Railtie < Rails::Railtie 7 | rake_tasks do 8 | load "tasks/sail_tasks.rake" 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/sail/types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | # Types 5 | # 6 | # This module holds all setting types 7 | # classes 8 | module Types 9 | autoload :Type, "sail/types/type" 10 | autoload :Boolean, "sail/types/boolean" 11 | autoload :Integer, "sail/types/integer" 12 | autoload :AbTest, "sail/types/ab_test" 13 | autoload :Array, "sail/types/array" 14 | autoload :Cron, "sail/types/cron" 15 | autoload :Date, "sail/types/date" 16 | autoload :Float, "sail/types/float" 17 | autoload :Locales, "sail/types/locales" 18 | autoload :ObjModel, "sail/types/obj_model" 19 | autoload :Range, "sail/types/range" 20 | autoload :String, "sail/types/string" 21 | autoload :Throttle, "sail/types/throttle" 22 | autoload :Uri, "sail/types/uri" 23 | autoload :Set, "sail/types/set" 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/sail/types/ab_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | module Types 5 | # AbTest 6 | # 7 | # The AbTest setting type returns 8 | # true or false randomly (50% chance). 9 | class AbTest < Boolean 10 | def to_value 11 | if @setting.value == Sail::ConstantCollection::TRUE 12 | Sail::ConstantCollection::BOOLEANS.sample 13 | else 14 | false 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/sail/types/array.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | module Types 5 | # Array 6 | # 7 | # This type allows defining an 8 | # array using a string and a separator 9 | # (defined in the configuration). 10 | class Array < Type 11 | def to_value 12 | @setting.value.split(Sail.configuration.array_separator) 13 | end 14 | 15 | def from(value) 16 | value.is_a?(::String) ? value : value.join(Sail.configuration.array_separator) 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/sail/types/boolean.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | module Types 5 | # Boolean 6 | # 7 | # The Boolean type simply returns true 8 | # or false depending on what is stored 9 | # in the database. 10 | class Boolean < Type 11 | def to_value 12 | @setting.value == Sail::ConstantCollection::TRUE 13 | end 14 | 15 | def from(value) 16 | if value.is_a?(::String) 17 | check_for_on_or_boolean(value) 18 | elsif value.nil? 19 | Sail::ConstantCollection::FALSE 20 | else 21 | value.to_s 22 | end 23 | end 24 | 25 | private 26 | 27 | def check_for_on_or_boolean(value) 28 | value == Sail::ConstantCollection::ON ? Sail::ConstantCollection::TRUE : value 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/sail/types/cron.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | module Types 5 | # Cron 6 | # 7 | # The Cron type returns true 8 | # if the saved cron string 9 | # matches the current time 10 | # (ignores seconds). 11 | class Cron < Type 12 | def to_value 13 | Fugit::Cron.new(@setting.value).match?(DateTime.now.utc.change(sec: 0)) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/sail/types/date.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | module Types 5 | # Date 6 | # 7 | # The Date type parses the saved 8 | # string into a DateTime object. 9 | class Date < Type 10 | def to_value 11 | DateTime.parse(@setting.value).utc 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/sail/types/float.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | module Types 5 | # Float 6 | # 7 | # The Float type manipulates the 8 | # saved string value into floats. 9 | class Float < Type 10 | def to_value 11 | @setting.value.to_f 12 | end 13 | 14 | def from(value) 15 | value.to_f 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/sail/types/integer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | module Types 5 | # Integer 6 | # 7 | # The Integer type manipulates the 8 | # saved string value into integers. 9 | class Integer < Type 10 | def to_value 11 | @setting.value.to_i 12 | end 13 | 14 | def from(value) 15 | value.to_i 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/sail/types/locales.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | module Types 5 | # Locales 6 | # 7 | # Locales settings will keep an array of locales. 8 | # If the current I18n.locale is included in the array, 9 | # the setting will return true. 10 | class Locales < Array 11 | def to_value 12 | @setting.value 13 | .split(Sail.configuration.array_separator) 14 | .include?(I18n.locale.to_s) 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/sail/types/obj_model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | module Types 5 | # ObjModel 6 | # 7 | # The ObjModel type returns the constant 8 | # for a given string saved. 9 | # For example: 10 | # 11 | # If the saved value is +"Post"+, 12 | # it will return +Post+ (actual class). 13 | class ObjModel < Type 14 | def to_value 15 | @setting.value.constantize 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/sail/types/range.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | module Types 5 | # Range 6 | # 7 | # The Range type is similar to an 8 | # integer, but has a minimum value 9 | # of 0 and a maximum value of 99. 10 | class Range < Integer; end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/sail/types/set.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | module Types 5 | # Set 6 | # 7 | # This type allows defining a set 8 | # using a string and a separator 9 | # (defined in the configuration). 10 | class Set < Type 11 | def to_value 12 | ::Set[*@setting.value.split(Sail.configuration.array_separator)] 13 | end 14 | 15 | def from(value) 16 | value.is_a?(::String) ? value : value.join(Sail.configuration.array_separator) 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/sail/types/string.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | module Types 5 | # String 6 | # 7 | # The String setting type 8 | # is the simplest. It only 9 | # stores a configurable value 10 | # in the database. 11 | class String < Type; end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/sail/types/throttle.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | module Types 5 | # Throttle 6 | # 7 | # The Throttle type returns true +X+% 8 | # of the time (randomly), where +X+ is 9 | # the value saved in the database. 10 | # 11 | # Example: 12 | # 13 | # If the setting value is 30, it will 14 | # return +true+ 30% of the time. 15 | class Throttle < Type 16 | def to_value 17 | 100 * rand <= @setting.value.to_f 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/sail/types/type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | module Types 5 | # Type 6 | # 7 | # This is the base class all types 8 | # inherit from. It is an abstract class 9 | # not supposed to be instantiated. 10 | class Type 11 | def initialize(setting) 12 | @setting = setting 13 | end 14 | 15 | def to_value 16 | @setting.value.to_s 17 | end 18 | 19 | def from(value) 20 | value 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/sail/types/uri.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | module Types 5 | # Uri 6 | # 7 | # The Uri type returns an URI 8 | # object based on the string 9 | # saved in the database. 10 | class Uri < Type 11 | def to_value 12 | URI(@setting.value) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/sail/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sail 4 | VERSION = "3.6.1" 5 | end 6 | -------------------------------------------------------------------------------- /lib/tasks/sail_tasks.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :sail do 4 | desc "Loads default setting configurations from sail.yml" 5 | task load_defaults: :environment do 6 | Sail::Setting.load_defaults(true) 7 | end 8 | 9 | desc "Creates sail.yml using the current state of the database" 10 | task create_config_file: :environment do 11 | Sail::Setting.database_to_file 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/true_class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Patch for Ruby's TrueClass 4 | class TrueClass 5 | def to_s 6 | Sail::ConstantCollection::TRUE 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /sail.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.push File.expand_path("lib", __dir__) 4 | require "sail/version" 5 | 6 | Gem::Specification.new do |s| 7 | s.name = "sail" 8 | s.version = Sail::VERSION 9 | s.authors = ["Vinicius Stock"].freeze 10 | s.email = ["vinicius.stock@outlook.com"].freeze 11 | s.homepage = "https://github.com/vinistock/sail" 12 | s.summary = "Sail is a lightweight Rails engine that brings an admin panel for managing configuration settings on a live Rails app." 13 | s.description = "Sail is a lightweight Rails engine that brings an admin panel for managing configuration settings on a live Rails app." 14 | s.license = "MIT" 15 | 16 | s.files = Dir["{app,config,lib}/**/*", 17 | "MIT-LICENSE", 18 | "Rakefile", 19 | "README.md"].reject { |path| path.include?("sail.gif") } 20 | 21 | s.required_ruby_version = ">= 2.5.0" 22 | 23 | s.add_dependency "fugit" 24 | s.add_dependency "rails", ">= 5.0.0" 25 | s.metadata = { 26 | "rubygems_mfa_required" => "true" 27 | } 28 | end 29 | -------------------------------------------------------------------------------- /spec/controllers/sail/profiles_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::ProfilesController, type: :controller do 4 | routes { Sail::Engine.routes } 5 | 6 | describe "POST create" do 7 | subject(:request) do 8 | post :create, params: { name: "profile" }, format: :js 9 | end 10 | 11 | it "returns ok" do 12 | request 13 | expect(response).to have_http_status(:ok) 14 | end 15 | 16 | it "invokes create_or_update_self from profiles" do 17 | expect(Sail::Profile).to receive(:create_or_update_self).with("profile").and_call_original 18 | request 19 | end 20 | 21 | context "when a profile with the same name exists" do 22 | before do 23 | Sail::Profile.create!(name: :profile) 24 | end 25 | 26 | it "returns ok for an update" do 27 | request 28 | expect(response).to have_http_status(:ok) 29 | end 30 | end 31 | end 32 | 33 | describe "PUT switch" do 34 | subject(:request) do 35 | put :switch, params: { name: "profile" }, format: :js 36 | end 37 | 38 | it "returns ok" do 39 | request 40 | expect(response).to have_http_status(:ok) 41 | end 42 | 43 | it "invokes switch from profiles" do 44 | expect(Sail::Profile).to receive(:switch).with("profile") 45 | request 46 | end 47 | end 48 | 49 | describe "DELETE destroy" do 50 | subject(:request) do 51 | delete :destroy, params: { name: "profile" }, format: :js 52 | end 53 | 54 | let!(:profile) { Sail::Profile.create!(name: :profile) } 55 | 56 | it "returns ok" do 57 | request 58 | expect(response).to have_http_status(:ok) 59 | end 60 | 61 | it "destroys profile" do 62 | expect { request }.to change(Sail::Profile, :count).by(-1) 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/controllers/sail/settings_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::SettingsController, type: :controller do 4 | routes { Sail::Engine.routes } 5 | before do 6 | Rails.cache.delete("setting_get_setting") 7 | allow(Rails.logger).to receive(:info) 8 | end 9 | 10 | describe "GET index" do 11 | subject { get :index, params: params } 12 | 13 | let(:params) { { page: "1" } } 14 | 15 | before do 16 | Sail::Setting.create!(name: :setting, cast_type: :string, value: :something) 17 | end 18 | 19 | it "queries settings with pagination" do 20 | expect(Sail::Setting).to receive(:paginated).with("1", 20) 21 | subject 22 | expect(response).to have_http_status(:ok) 23 | end 24 | 25 | it "sets eTag in response headers" do 26 | subject 27 | expect(response.headers["ETag"]).to_not be_nil 28 | end 29 | 30 | it "sets the number of pages" do 31 | subject 32 | expect(controller.instance_variable_get(:@number_of_pages)).to eq(1) 33 | end 34 | 35 | context "when passing a query" do 36 | let(:params) { { query: "test" } } 37 | 38 | it "invokes proper scope with query" do 39 | expect(Sail::Setting).to receive(:by_name).with("test").and_call_original 40 | subject 41 | end 42 | end 43 | 44 | context "when passing a field for ordering" do 45 | let(:params) { { order_field: "updated_at" } } 46 | let!(:setting) { Sail::Setting.create!(name: :setting_2, cast_type: :string, value: :something_else, updated_at: 2.days.ago) } 47 | 48 | it "invokes ordered_by properly" do 49 | subject 50 | expect(controller.instance_variable_get(:@settings).last.name).to eq(setting.name) 51 | end 52 | end 53 | end 54 | 55 | describe "PUT update" do 56 | subject { put :update, params: { name: setting.name, value: new_value, cast_type: setting.cast_type }, format: :js } 57 | 58 | let!(:setting) { Sail::Setting.create(name: :setting, cast_type: :string, value: "old value") } 59 | let(:new_value) { "new value" } 60 | 61 | it "updates setting value" do 62 | expect(setting.value).to eq("old value") 63 | subject 64 | expect(response).to have_http_status(:ok) 65 | expect(setting.reload.value).to eq("new value") 66 | end 67 | 68 | it "logs change information" do 69 | expect(Rails.logger).to receive(:info).with(/.* \[Sail\] Update setting='setting' value='new value' author_user_id=1/) 70 | subject 71 | end 72 | 73 | context "when setting is boolean" do 74 | let!(:setting) { Sail::Setting.create(name: :setting, cast_type: :boolean, value: "false") } 75 | let(:new_value) { "on" } 76 | 77 | it "updates setting value" do 78 | expect(setting.value).to eq("false") 79 | subject 80 | expect(response).to have_http_status(:ok) 81 | expect(setting.reload.value).to eq("true") 82 | end 83 | end 84 | 85 | context "when format is JSON" do 86 | subject { put :update, params: { name: setting.name, value: new_value, cast_type: setting.cast_type }, format: :json } 87 | 88 | it "updates setting value" do 89 | expect(setting.value).to eq("old value") 90 | subject 91 | expect(response).to have_http_status(:ok) 92 | expect(setting.reload.value).to eq("new value") 93 | end 94 | 95 | context "when update fails" do 96 | before do 97 | allow(Sail::Setting).to receive(:set).and_return([nil, false]) 98 | end 99 | 100 | it "returns http status conflict" do 101 | subject 102 | expect(response).to have_http_status(:conflict) 103 | end 104 | 105 | it "does not log changes" do 106 | expect(Rails.logger).to_not receive(:info).with(/.* \[Sail\] Update setting='setting' value='new value' author_user_id=1/) 107 | subject 108 | end 109 | end 110 | end 111 | end 112 | 113 | describe "GET show" do 114 | subject { get :show, params: params, format: :json } 115 | 116 | let!(:setting) { Sail::Setting.create(name: :setting, cast_type: :string, value: "some value") } 117 | let(:params) { { name: setting.name } } 118 | 119 | it "returns setting value" do 120 | subject 121 | body = JSON.parse(response.body) 122 | expect(body["value"]).to eq(setting.value) 123 | end 124 | 125 | it "returns 200 status" do 126 | subject 127 | expect(response).to have_http_status(:ok) 128 | end 129 | 130 | it "responds in json format" do 131 | subject 132 | 133 | type = Rails::VERSION::MAJOR < 6 ? response.content_type : response.media_type 134 | 135 | expect(type).to eq("application/json") 136 | end 137 | end 138 | 139 | describe "GET switcher" do 140 | subject { get :switcher, params: params, format: :json } 141 | 142 | let!(:throttle) { Sail::Setting.create(name: :throttle, cast_type: :throttle, value: "50.0") } 143 | let(:params) { { positive: :positive, negative: :negative, throttled_by: :throttle } } 144 | 145 | before do 146 | Rails.cache.delete("setting_get_positive") 147 | Rails.cache.delete("setting_get_negative") 148 | Rails.cache.delete("setting_get_throttle") 149 | Sail::Setting.create!(name: :positive, cast_type: :string, value: "I'm the primary!") 150 | Sail::Setting.create!(name: :negative, cast_type: :integer, value: "7") 151 | allow_any_instance_of(Sail::Types::Throttle).to receive(:rand).and_return(random_value) 152 | end 153 | 154 | context "when random value is smaller than throttle" do 155 | let(:random_value) { 0.25 } 156 | 157 | it "returns ok status" do 158 | subject 159 | expect(response).to have_http_status(:ok) 160 | end 161 | 162 | it "returns value of positive setting" do 163 | subject 164 | 165 | body = JSON.parse(response.body) 166 | expect(body["value"]).to eq("I'm the primary!") 167 | end 168 | end 169 | 170 | context "when random value is greater than throttle" do 171 | let(:random_value) { 0.75 } 172 | 173 | it "returns ok status" do 174 | subject 175 | expect(response).to have_http_status(:ok) 176 | end 177 | 178 | it "returns value of negative setting" do 179 | subject 180 | 181 | body = JSON.parse(response.body) 182 | expect(body["value"]).to eq(7) 183 | end 184 | end 185 | 186 | context "when throttle setting is of the wrong type" do 187 | let!(:throttle) { Sail::Setting.create!(name: :throttle, cast_type: :boolean, value: "true") } 188 | let(:random_value) { 0.75 } 189 | 190 | it "returns bad request" do 191 | subject 192 | expect(response).to have_http_status(:bad_request) 193 | end 194 | end 195 | 196 | context "when throttle setting does not exist" do 197 | let!(:throttle) { Sail::Setting.create!(name: :wrong_name, cast_type: :boolean, value: "true") } 198 | let(:random_value) { 0.75 } 199 | 200 | it "returns not found" do 201 | subject 202 | expect(response).to have_http_status(:not_found) 203 | end 204 | end 205 | end 206 | 207 | describe "PUT reset" do 208 | subject { put :reset, params: { name: setting.name }, format: :js } 209 | 210 | let!(:setting) { Sail::Setting.create(name: :setting, cast_type: :string, value: "old value") } 211 | let(:file_contents) { { "setting" => { "value" => "new value" } } } 212 | 213 | before do 214 | allow(File).to receive(:exist?) 215 | allow(Sail::Setting).to receive(:config_file_path).and_return("./config/sail.yml") 216 | allow(File).to receive(:exist?).with("./config/sail.yml").and_return(true) 217 | allow(YAML).to receive(:load_file).with("./config/sail.yml").and_return(file_contents) 218 | end 219 | 220 | it "resets setting value" do 221 | expect(Sail::Setting).to receive(:reset).with("setting").and_call_original 222 | subject 223 | expect(response).to have_http_status(:ok) 224 | expect(setting.reload.value).to eq("new value") 225 | end 226 | 227 | it "logs change information" do 228 | expect(Rails.logger).to receive(:info).with(/.* \[Sail\] Reset setting='setting' value='new value' author_user_id=1/) 229 | subject 230 | end 231 | 232 | context "when update fails" do 233 | before do 234 | allow(Sail::Setting).to receive(:set).and_return([nil, false]) 235 | end 236 | 237 | it "does not log changes" do 238 | expect(Rails.logger).to_not receive(:info).with(/.* \[Sail\] Update setting='setting' value='new value' author_user_id=1/) 239 | subject 240 | end 241 | end 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'config/application' 4 | Rails.application.load_tasks 5 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | 2 | //= link_tree ../images 3 | //= link_directory ../javascripts .js 4 | //= link_directory ../stylesheets .css 5 | //= link sail_manifest.js 6 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinistock/sail/65f7bcadd155fff87088d15730356cd5ce33dd17/spec/dummy/app/assets/images/.keep -------------------------------------------------------------------------------- /spec/dummy/app/assets/javascripts/application.js.erb: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. JavaScript code in this file should be added after the last require_* statement. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | <% 14 | require_asset("rails-ujs") 15 | %> 16 | //= require_tree . 17 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/javascripts/cable.js.erb: -------------------------------------------------------------------------------- 1 | // Action Cable provides the framework to deal with WebSockets in Rails. 2 | // You can generate new channels where WebSocket features live using the `rails generate channel` command. 3 | // 4 | <% require_asset("action_cable") %> 5 | //= require_self 6 | //= require_tree ./channels 7 | 8 | (function() { 9 | this.App || (this.App = {}); 10 | 11 | App.cable = ActionCable.createConsumer(); 12 | 13 | }).call(this); 14 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/javascripts/channels/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinistock/sail/65f7bcadd155fff87088d15730356cd5ce33dd17/spec/dummy/app/assets/javascripts/channels/.keep -------------------------------------------------------------------------------- /spec/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /spec/dummy/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | protect_from_forgery with: :exception 3 | before_action :set_locale 4 | 5 | def index 6 | random_setting = Sail::Setting.pluck(:name) 7 | 5.times { Sail.get(random_setting.sample) } 8 | end 9 | 10 | protected 11 | 12 | def set_locale 13 | I18n.locale = params[:locale] || :en 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinistock/sail/65f7bcadd155fff87088d15730356cd5ce33dd17/spec/dummy/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /spec/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: 'from@example.com' 3 | layout 'mailer' 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinistock/sail/65f7bcadd155fff87088d15730356cd5ce33dd17/spec/dummy/app/models/concerns/.keep -------------------------------------------------------------------------------- /spec/dummy/app/models/namespace/my_model.rb: -------------------------------------------------------------------------------- 1 | class Namespace::MyModel < ApplicationRecord 2 | 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/models/test.rb: -------------------------------------------------------------------------------- 1 | class Test < ApplicationRecord 2 | 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/models/test2.rb: -------------------------------------------------------------------------------- 1 | class Test2 < ApplicationRecord 2 | 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/views/application/index.html.erb: -------------------------------------------------------------------------------- 1 |

Inside dummy app

2 | 3 | <%= link_to("Sail", "/sail?locale=#{params[:locale]}", method: :get) %> 4 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | <%= stylesheet_link_tag 'application', media: 'all' %> 6 | <%= javascript_include_tag 'application', async: true %> 7 | <%= csrf_meta_tags %> 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /spec/dummy/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../config/application', __dir__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | require 'fileutils' 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a starting point to setup your application. 15 | # Add necessary setup steps to this file. 16 | 17 | puts '== Installing dependencies ==' 18 | system! 'gem install bundler --conservative' 19 | system('bundle check') || system!('bundle install') 20 | 21 | # Install JavaScript dependencies if using Yarn 22 | # system('bin/yarn') 23 | 24 | 25 | # puts "\n== Copying sample files ==" 26 | # unless File.exist?('config/database.yml') 27 | # cp 'config/database.yml.sample', 'config/database.yml' 28 | # end 29 | 30 | puts "\n== Preparing database ==" 31 | system! 'bin/rails db:setup' 32 | 33 | puts "\n== Removing old logs and tempfiles ==" 34 | system! 'bin/rails log:clear tmp:clear' 35 | 36 | puts "\n== Restarting application server ==" 37 | system! 'bin/rails restart' 38 | end 39 | -------------------------------------------------------------------------------- /spec/dummy/bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | require 'fileutils' 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a way to update your development environment automatically. 15 | # Add necessary update steps to this file. 16 | 17 | puts '== Installing dependencies ==' 18 | system! 'gem install bundler --conservative' 19 | system('bundle check') || system!('bundle install') 20 | 21 | puts "\n== Updating database ==" 22 | system! 'bin/rails db:migrate' 23 | 24 | puts "\n== Removing old logs and tempfiles ==" 25 | system! 'bin/rails log:clear tmp:clear' 26 | 27 | puts "\n== Restarting application server ==" 28 | system! 'bin/rails restart' 29 | end 30 | -------------------------------------------------------------------------------- /spec/dummy/bin/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | VENDOR_PATH = File.expand_path('..', __dir__) 3 | Dir.chdir(VENDOR_PATH) do 4 | begin 5 | exec "yarnpkg #{ARGV.join(" ")}" 6 | rescue Errno::ENOENT 7 | $stderr.puts "Yarn executable was not detected in the system." 8 | $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install" 9 | exit 1 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'config/environment' 4 | run Rails.application 5 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'boot' 4 | 5 | require 'rails/all' 6 | 7 | Bundler.require(*Rails.groups) 8 | require 'sail' 9 | 10 | module Dummy 11 | class Application < Rails::Application 12 | if Rails::VERSION::MAJOR < 6 13 | config.active_record.sqlite3.represent_boolean_as_integer = true 14 | config.load_defaults 5.0 15 | else 16 | config.load_defaults 6.1 17 | end 18 | 19 | Sail.configure do |config| 20 | config.enable_search_auto_submit = true 21 | end 22 | 23 | I18n.available_locales = %i[en es fr] 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Set up gems listed in the Gemfile. 4 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) 5 | 6 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 7 | $LOAD_PATH.unshift File.expand_path('../../../lib', __dir__) 8 | -------------------------------------------------------------------------------- /spec/dummy/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: async 6 | 7 | production: 8 | adapter: redis 9 | url: redis://localhost:6379/1 10 | channel_prefix: dummy_production 11 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite version 3.x 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'application' 4 | Rails.application.initialize! 5 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded on 7 | # every request. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable/disable caching. By default caching is disabled. 18 | if Rails.root.join('tmp/caching-dev.txt').exist? 19 | config.action_controller.perform_caching = true 20 | 21 | config.cache_store = :memory_store 22 | 23 | config.public_file_server.headers = { 24 | 'Cache-Control' => "public, max-age=#{2.days.seconds.to_i}" 25 | } 26 | else 27 | config.action_controller.perform_caching = false 28 | 29 | config.cache_store = :null_store 30 | end 31 | 32 | # Don't care if the mailer can't send. 33 | config.action_mailer.raise_delivery_errors = false 34 | 35 | config.action_mailer.perform_caching = false 36 | 37 | # Print deprecation notices to the Rails logger. 38 | config.active_support.deprecation = :log 39 | 40 | # Raise an error on page load if there are pending migrations. 41 | config.active_record.migration_error = :page_load 42 | 43 | # Debug mode disables concatenation and preprocessing of assets. 44 | # This option may cause significant delays in view rendering with a large 45 | # number of complex assets. 46 | config.assets.debug = true 47 | 48 | # Suppress logger output for asset requests. 49 | config.assets.quiet = true 50 | 51 | # Raises error for missing translations 52 | # config.action_view.raise_on_missing_translations = true 53 | 54 | # Use an evented file watcher to asynchronously detect changes in source code, 55 | # routes, locales, etc. This feature depends on the listen gem. 56 | # config.file_watcher = ActiveSupport::EventedFileUpdateChecker 57 | end 58 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.cache_classes = true 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Attempt to read encrypted secrets from `config/secrets.yml.enc`. 20 | # Requires an encryption key in `ENV["RAILS_MASTER_KEY"]` or 21 | # `config/secrets.yml.key`. 22 | config.read_encrypted_secrets = true 23 | 24 | # Disable serving static files from the `/public` folder by default since 25 | # Apache or NGINX already handles this. 26 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? 27 | 28 | # Compress JavaScripts and CSS. 29 | config.assets.js_compressor = :uglifier 30 | # config.assets.css_compressor = :sass 31 | 32 | # Do not fallback to assets pipeline if a precompiled asset is missed. 33 | config.assets.compile = false 34 | 35 | # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb 36 | 37 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 38 | # config.action_controller.asset_host = 'http://assets.example.com' 39 | 40 | # Specifies the header that your server uses for sending files. 41 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 42 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 43 | 44 | # Mount Action Cable outside main process or domain 45 | # config.action_cable.mount_path = nil 46 | # config.action_cable.url = 'wss://example.com/cable' 47 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] 48 | 49 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 50 | # config.force_ssl = true 51 | 52 | # Use the lowest log level to ensure availability of diagnostic information 53 | # when problems arise. 54 | config.log_level = :debug 55 | 56 | # Prepend all log lines with the following tags. 57 | config.log_tags = [ :request_id ] 58 | 59 | # Use a different cache store in production. 60 | # config.cache_store = :mem_cache_store 61 | 62 | # Use a real queuing backend for Active Job (and separate queues per environment) 63 | # config.active_job.queue_adapter = :resque 64 | # config.active_job.queue_name_prefix = "dummy_#{Rails.env}" 65 | config.action_mailer.perform_caching = false 66 | 67 | # Ignore bad email addresses and do not raise email delivery errors. 68 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 69 | # config.action_mailer.raise_delivery_errors = false 70 | 71 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 72 | # the I18n.default_locale when a translation cannot be found). 73 | config.i18n.fallbacks = true 74 | 75 | # Send deprecation notices to registered listeners. 76 | config.active_support.deprecation = :notify 77 | 78 | # Use default logging formatter so that PID and timestamp are not suppressed. 79 | config.log_formatter = ::Logger::Formatter.new 80 | 81 | # Use a different logger for distributed setups. 82 | # require 'syslog/logger' 83 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 84 | 85 | if ENV["RAILS_LOG_TO_STDOUT"].present? 86 | logger = ActiveSupport::Logger.new(STDOUT) 87 | logger.formatter = config.log_formatter 88 | config.logger = ActiveSupport::TaggedLogging.new(logger) 89 | end 90 | 91 | # Do not dump schema after migrations. 92 | config.active_record.dump_schema_after_migration = false 93 | end 94 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # The test environment is used exclusively to run your application's 7 | # test suite. You never need to work with it otherwise. Remember that 8 | # your test database is "scratch space" for the test suite and is wiped 9 | # and recreated between test runs. Don't rely on the data there! 10 | config.cache_classes = true 11 | 12 | # Do not eager load code on boot. This avoids loading your whole application 13 | # just for the purpose of running a single test. If you are using a tool that 14 | # preloads Rails for running tests, you may have to set it to true. 15 | config.eager_load = false 16 | 17 | # Configure public file server for tests with Cache-Control for performance. 18 | config.public_file_server.enabled = true 19 | config.public_file_server.headers = { 20 | 'Cache-Control' => "public, max-age=#{1.hour.seconds.to_i}" 21 | } 22 | 23 | # Show full error reports and disable caching. 24 | config.consider_all_requests_local = true 25 | config.action_controller.perform_caching = false 26 | 27 | # Raise exceptions instead of rendering exception templates. 28 | config.action_dispatch.show_exceptions = false 29 | 30 | # Disable request forgery protection in test environment. 31 | config.action_controller.allow_forgery_protection = false 32 | config.action_mailer.perform_caching = false 33 | 34 | # Tell Action Mailer not to deliver emails to the real world. 35 | # The :test delivery method accumulates sent emails in the 36 | # ActionMailer::Base.deliveries array. 37 | config.action_mailer.delivery_method = :test 38 | 39 | # Print deprecation notices to the stderr. 40 | config.active_support.deprecation = :stderr 41 | 42 | # Raises error for missing translations 43 | # config.action_view.raise_on_missing_translations = true 44 | end 45 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Version of your assets, change this if you want to expire all your assets. 6 | Rails.application.config.assets.version = '1.0' 7 | 8 | # Add additional assets to the asset load path. 9 | # Rails.application.config.assets.paths << Emoji.images_path 10 | # Add Yarn node_modules folder to the asset load path. 11 | Rails.application.config.assets.paths << Rails.root.join('node_modules') 12 | 13 | # Precompile additional assets. 14 | # application.js, application.css, and all non-JS/CSS in the app/assets 15 | # folder are already added. 16 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 17 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /spec/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at http://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /spec/dummy/config/locales/fr.yml: -------------------------------------------------------------------------------- 1 | fr: 2 | sail: 3 | page_title: Le dashboard 4 | -------------------------------------------------------------------------------- /spec/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Initialize the Rails application. 2 | 3 | # Puma can serve each request in a thread from an internal thread pool. 4 | # The `threads` method setting takes two numbers: a minimum and maximum. 5 | # Any libraries that use thread pools should be configured to match 6 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 7 | # and maximum; this matches the default thread size of Active Record. 8 | # 9 | threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 10 | threads threads_count, threads_count 11 | 12 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 13 | # 14 | port ENV.fetch("PORT") { 3000 } 15 | 16 | # Specifies the `environment` that Puma will run in. 17 | # 18 | environment ENV.fetch("RAILS_ENV") { "development" } 19 | 20 | # Specifies the number of `workers` to boot in clustered mode. 21 | # Workers are forked webserver processes. If using threads and workers together 22 | # the concurrency of the application would be max `threads` * `workers`. 23 | # Workers do not work on JRuby or Windows (both of which do not support 24 | # processes). 25 | # 26 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 27 | 28 | # Use the `preload_app!` method when specifying a `workers` number. 29 | # This directive tells Puma to first boot the application and load code 30 | # before forking the application. This takes advantage of Copy On Write 31 | # process behavior so workers use less memory. If you use this option 32 | # you need to make sure to reconnect any threads in the `on_worker_boot` 33 | # block. 34 | # 35 | # preload_app! 36 | 37 | # If you are preloading your application and using Active Record, it's 38 | # recommended that you close any connections to the database before workers 39 | # are forked to prevent connection leakage. 40 | # 41 | # before_fork do 42 | # ActiveRecord::Base.connection_pool.disconnect! if defined?(ActiveRecord) 43 | # end 44 | 45 | # The code in the `on_worker_boot` will be called if you are using 46 | # clustered mode by specifying a number of `workers`. After each worker 47 | # process is booted, this block will be run. If you are using the `preload_app!` 48 | # option, you will want to use this block to reconnect to any threads 49 | # or connections that may have been created at application boot, as Ruby 50 | # cannot share connections between processes. 51 | # 52 | # on_worker_boot do 53 | # ActiveRecord::Base.establish_connection if defined?(ActiveRecord) 54 | # end 55 | # 56 | 57 | # Allow puma to be restarted by `rails restart` command. 58 | plugin :tmp_restart 59 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | # Initialize the Rails application. 2 | 3 | Rails.application.routes.draw do 4 | root "application#index" 5 | mount Sail::Engine => '/sail' 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy/config/sail.yml: -------------------------------------------------------------------------------- 1 | number_of_parallel_jobs: 2 | description: Maximum number of parallel jobs 3 | value: 2 4 | cast_type: integer 5 | group: jobs 6 | enable_awesome_feature: 7 | description: Enable the awesome feature! 8 | value: 'true' 9 | cast_type: boolean 10 | group: feature_flags 11 | partner_url: 12 | description: URL to connect for partner service 13 | value: 'https://mypartner.com' 14 | cast_type: uri 15 | group: app 16 | promotion_expire_date: 17 | description: Expiration date for winter promotion 18 | value: '2019-01-15T9:00' 19 | cast_type: date 20 | group: app 21 | admins: 22 | description: List of system admins 23 | value: John;Ted;Mark 24 | cast_type: array 25 | group: adm 26 | article_model: 27 | description: Article model to be used for posts 28 | value: 'Post' 29 | cast_type: obj_model 30 | group: app 31 | new_design_percentage: 32 | description: Percentage of users that can see the new design 33 | value: '25' 34 | cast_type: throttle 35 | group: feature_flags 36 | lucky_one_cadence: 37 | description: Cron definition for gitfting lucky users 38 | value: '6 10 2 1 *' 39 | cast_type: cron 40 | group: app 41 | max_survey_answers: 42 | description: Maximum answers per user for a survey 43 | value: 3 44 | cast_type: integer 45 | group: app 46 | ab_tester: 47 | description: General purpose ab test setting 48 | value: 'false' 49 | cast_type: ab_test 50 | group: feature_flags 51 | scaler: 52 | description: A scaler 53 | value: 33 54 | cast_type: range 55 | group: tuners 56 | -------------------------------------------------------------------------------- /spec/dummy/config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rails secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | # Shared secrets are available across all environments. 14 | 15 | # shared: 16 | # api_key: a1B2c3D4e5F6 17 | 18 | # Environmental secrets are only available for that specific environment. 19 | 20 | development: 21 | secret_key_base: a66204353250ec209f37abe12a780e7f5767401475f85fecbdcda515ce0916d25569c33669c3169d5f962605ac61d33aa2e3090464f5255b1d761b83fb09eb41 22 | 23 | test: 24 | secret_key_base: 632ff2eeb0f1d907c4794f48bee1f6d631a3d5df46b71397385e1b614c24955f4bd89d6d9bd7cf6eba819576884f95f5b1e3188b6ee1a945109860145ecfcfdf 25 | 26 | # Do not keep production secrets in the unencrypted secrets file. 27 | # Instead, either read values from the environment. 28 | # Or, use `bin/rails secrets:setup` to configure encrypted secrets 29 | # and move the `production:` environment over there. 30 | 31 | production: 32 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 33 | -------------------------------------------------------------------------------- /spec/dummy/config/spring.rb: -------------------------------------------------------------------------------- 1 | # Initialize the Rails application. 2 | 3 | %w( 4 | .ruby-version 5 | .rbenv-vars 6 | tmp/restart.txt 7 | tmp/caching-dev.txt 8 | ).each { |path| Spring.watch(path) } 9 | -------------------------------------------------------------------------------- /spec/dummy/db/development.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinistock/sail/65f7bcadd155fff87088d15730356cd5ce33dd17/spec/dummy/db/development.sqlite3 -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20171026214947_create_tables_for_models.rb: -------------------------------------------------------------------------------- 1 | class CreateTablesForModels < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :tests do |t| 4 | t.string :name 5 | t.integer :value 6 | t.boolean :real 7 | t.text :content 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20180905005346_create_sail_setting.rb: -------------------------------------------------------------------------------- 1 | class CreateSailSetting < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :sail_settings do |t| 4 | t.string :name, null: false 5 | t.text :description 6 | t.string :value, null: false 7 | t.integer :cast_type, null: false, limit: 2 8 | t.timestamps 9 | t.index ["name"], name: "index_settings_on_name", unique: true 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20181220171659_add_group_to_settings.rb: -------------------------------------------------------------------------------- 1 | class AddGroupToSettings < ActiveRecord::Migration[5.2] 2 | def change 3 | add_column(:sail_settings, :group, :string) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20190207182505_create_sail_profiles.rb: -------------------------------------------------------------------------------- 1 | class CreateSailProfiles < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :sail_entries do |t| 4 | t.string :value, null: false 5 | t.references :setting, index: true 6 | t.references :profile, index: true 7 | t.timestamps 8 | end 9 | 10 | create_table :sail_profiles do |t| 11 | t.string :name, null: false 12 | t.index ["name"], name: "index_sail_profiles_on_name", unique: true 13 | t.timestamps 14 | end 15 | 16 | add_foreign_key(:sail_entries, :sail_settings, column: :setting_id) 17 | add_foreign_key(:sail_entries, :sail_profiles, column: :profile_id) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20190221151558_add_active_to_profiles.rb: -------------------------------------------------------------------------------- 1 | class AddActiveToProfiles < ActiveRecord::Migration[5.2] 2 | def change 3 | add_column(:sail_profiles, :active, :boolean, default: false) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20190606160450_remove_tests.rb: -------------------------------------------------------------------------------- 1 | class RemoveTests < ActiveRecord::Migration[5.2] 2 | def change 3 | drop_table :tests 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # Note that this schema.rb definition is the authoritative source for your 6 | # database schema. If you need to create the application database on another 7 | # system, you should be using db:schema:load, not running all the migrations 8 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 9 | # you'll amass, the slower it'll run and the greater likelihood for issues). 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 2019_06_06_160450) do 14 | 15 | create_table "sail_entries", force: :cascade do |t| 16 | t.string "value", null: false 17 | t.integer "setting_id" 18 | t.integer "profile_id" 19 | t.datetime "created_at", null: false 20 | t.datetime "updated_at", null: false 21 | t.index ["profile_id"], name: "index_sail_entries_on_profile_id" 22 | t.index ["setting_id"], name: "index_sail_entries_on_setting_id" 23 | end 24 | 25 | create_table "sail_profiles", force: :cascade do |t| 26 | t.string "name", null: false 27 | t.datetime "created_at", null: false 28 | t.datetime "updated_at", null: false 29 | t.boolean "active", default: false 30 | t.index ["name"], name: "index_sail_profiles_on_name", unique: true 31 | end 32 | 33 | create_table "sail_settings", force: :cascade do |t| 34 | t.string "name", null: false 35 | t.text "description" 36 | t.string "value", null: false 37 | t.integer "cast_type", limit: 2, null: false 38 | t.datetime "created_at", null: false 39 | t.datetime "updated_at", null: false 40 | t.string "group" 41 | t.index ["name"], name: "index_settings_on_name", unique: true 42 | end 43 | 44 | end 45 | -------------------------------------------------------------------------------- /spec/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinistock/sail/65f7bcadd155fff87088d15730356cd5ce33dd17/spec/dummy/lib/assets/.keep -------------------------------------------------------------------------------- /spec/dummy/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinistock/sail/65f7bcadd155fff87088d15730356cd5ce33dd17/spec/dummy/log/.keep -------------------------------------------------------------------------------- /spec/dummy/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dummy", 3 | "private": true, 4 | "dependencies": {} 5 | } 6 | -------------------------------------------------------------------------------- /spec/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /spec/dummy/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinistock/sail/65f7bcadd155fff87088d15730356cd5ce33dd17/spec/dummy/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /spec/dummy/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinistock/sail/65f7bcadd155fff87088d15730356cd5ce33dd17/spec/dummy/public/apple-touch-icon.png -------------------------------------------------------------------------------- /spec/dummy/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinistock/sail/65f7bcadd155fff87088d15730356cd5ce33dd17/spec/dummy/public/favicon.ico -------------------------------------------------------------------------------- /spec/dummy/tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinistock/sail/65f7bcadd155fff87088d15730356cd5ce33dd17/spec/dummy/tmp/.keep -------------------------------------------------------------------------------- /spec/features/editing_settings_feature_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | feature "editing settings", js: true, type: :feature do 4 | [ 5 | { type: "float", old: "1.532", new: "1.324" }, 6 | { type: "integer", old: "15", new: "8" }, 7 | { type: "array", old: "John;Ted", new: "John;Ted;Mark" }, 8 | { type: "string", old: "old_value", new: "new_value" }, 9 | { type: "ab_test", old: "true", new: "false" }, 10 | { type: "cron", old: "* * * * *", new: "*/5 * 10 * *" }, 11 | { type: "obj_model", old: "Test2", new: "Test" }, 12 | { type: "uri", old: "https://youtube.com", new: "https://google.com" }, 13 | { type: "boolean", old: "false", new: "true" }, 14 | { type: "date", old: "2010-01-30", new: DateTime.parse("2018-01-30").utc } 15 | ].each do |set| 16 | context "when setting is of type #{set[:type]}" do 17 | let!(:setting) { Sail::Setting.create(name: :setting, cast_type: set[:type], value: set[:old]) } 18 | 19 | before do 20 | visit "/sail" 21 | end 22 | 23 | it "properly changes the setting's value" do 24 | within(".card") do 25 | expect(page).to have_button("SAVE", disabled: true) 26 | send("fill_for_#{set[:type]}", set[:new]) 27 | page.execute_script("document.getElementById('input_for_setting').dispatchEvent(new Event('change'))") 28 | click_button("SAVE") 29 | expect(page).to have_css(".success") 30 | end 31 | 32 | if set[:type] == "date" 33 | expect(setting.reload.value).to match(/2018-.*-.* .*:.*/) 34 | else 35 | expect(setting.reload.value).to eq(set[:new]) 36 | end 37 | end 38 | end 39 | end 40 | 41 | private 42 | 43 | def fill_for_float(value) 44 | fill_in("value", with: value) 45 | end 46 | 47 | def fill_for_integer(value) 48 | fill_in("value", with: value) 49 | end 50 | 51 | def fill_for_array(value) 52 | fill_in("value", with: value) 53 | end 54 | 55 | def fill_for_string(value) 56 | fill_in("value", with: value) 57 | end 58 | 59 | def fill_for_ab_test(*) 60 | find("label.switch").click 61 | end 62 | 63 | def fill_for_cron(value) 64 | fill_in("value", with: value) 65 | end 66 | 67 | def fill_for_obj_model(value) 68 | fill_in("value", with: value) 69 | end 70 | 71 | def fill_for_uri(value) 72 | fill_in("value", with: value) 73 | end 74 | 75 | def fill_for_boolean(*) 76 | find("label.switch").click 77 | end 78 | 79 | def fill_for_date(value) 80 | fill_in("value", with: value) 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/features/managing_profiles_feature_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | feature "managing profiles", js: true, type: :feature do 4 | let!(:setting_1) do 5 | Sail::Setting.create(name: :setting, cast_type: :string, 6 | value: :something) 7 | end 8 | 9 | let!(:setting_2) do 10 | Sail::Setting.create(name: :configuration, cast_type: :string, 11 | value: :something) 12 | end 13 | 14 | before do 15 | visit "/sail" 16 | find("#btn-profiles").click 17 | end 18 | 19 | it "allows closing modal using escape" do 20 | expect(page).to have_css("#profiles-modal", visible: true) 21 | find("body").native.send_keys(:escape) 22 | expect(page).to have_css("#profiles-modal", visible: false) 23 | end 24 | 25 | it "allows creating new profiles" do 26 | within("#profiles-modal") do 27 | expect(Sail::Profile.count).to be_zero 28 | fill_in("new-profile-input", with: "Profile 1") 29 | click_button("SAVE") 30 | expect(page).to have_content("Created") 31 | expect(page).to have_content("Profile 1") 32 | expect(page).to have_button("ACTIVATE") 33 | expect(page).to have_button("DELETE") 34 | expect(Sail::Profile.count).to eq(1) 35 | 36 | entries = Sail::Profile.first.entries 37 | expect([setting_1, setting_2]).to include(entries.first.setting) 38 | expect([setting_1, setting_2]).to include(entries.second.setting) 39 | end 40 | end 41 | 42 | it "allows activating profiles" do 43 | within("#profiles-modal") do 44 | expect(Sail::Profile.count).to be_zero 45 | fill_in("new-profile-input", with: "Profile 1") 46 | click_button("SAVE") 47 | expect(page).to have_content("Created") 48 | expect(page).to have_content("Profile 1") 49 | expect(page).to have_button("ACTIVATE") 50 | expect(page).to have_button("DELETE") 51 | expect(Sail::Profile.count).to eq(1) 52 | 53 | setting_1.update!(value: :something_else) 54 | 55 | click_button("ACTIVATE") 56 | expect(page).to have_content("Switching..") 57 | expect(setting_1.reload.value).to eq("something") 58 | end 59 | 60 | expect(page).to have_no_css("#profiles-modal") 61 | 62 | find("#btn-profiles").click 63 | 64 | within("#profiles-modal") do 65 | expect(page).to have_css(".active-indicator") 66 | expect(page).to have_css(".green") 67 | end 68 | end 69 | 70 | it "allows deleting profiles" do 71 | within("#profiles-modal") do 72 | expect(Sail::Profile.count).to be_zero 73 | fill_in("new-profile-input", with: "Profile 1") 74 | click_button("SAVE") 75 | expect(page).to have_content("Created") 76 | expect(page).to have_content("Profile 1") 77 | expect(page).to have_button("ACTIVATE") 78 | expect(page).to have_button("DELETE") 79 | expect(Sail::Profile.count).to eq(1) 80 | 81 | click_button("DELETE") 82 | expect(page).to have_content("Deleted") 83 | expect(page).to have_no_content("Profile 1") 84 | expect(page).to have_no_button("ACTIVATE") 85 | expect(page).to have_no_button("DELETE") 86 | expect(Sail::Profile.count).to be_zero 87 | end 88 | end 89 | 90 | it "allows saving existing profiles" do 91 | within("#profiles-modal") do 92 | expect(Sail::Profile.count).to be_zero 93 | fill_in("new-profile-input", with: "Profile 1") 94 | click_button("SAVE") 95 | expect(page).to have_content("Created") 96 | expect(page).to have_content("Profile 1") 97 | expect(page).to have_button("ACTIVATE") 98 | expect(page).to have_button("DELETE") 99 | expect(Sail::Profile.count).to eq(1) 100 | 101 | setting_1.update!(value: :something_else) 102 | 103 | within(all(".profile-entry")[1]) do 104 | click_button("SAVE") 105 | end 106 | 107 | expect(page).to have_content("Saved") 108 | expect(page).to have_content("Profile 1") 109 | expect(page).to have_button("ACTIVATE") 110 | expect(page).to have_button("DELETE") 111 | expect(Sail::Profile.count).to eq(1) 112 | end 113 | end 114 | 115 | it "displays number of uncaught errors" do 116 | within("#profiles-modal") do 117 | expect(Sail::Profile.count).to be_zero 118 | fill_in("new-profile-input", with: "Profile 1") 119 | click_button("SAVE") 120 | expect(page).to have_content("Created") 121 | expect(page).to have_content("Profile 1") 122 | expect(page).to have_button("ACTIVATE") 123 | expect(page).to have_button("DELETE") 124 | expect(Sail::Profile.count).to eq(1) 125 | 126 | Sail.instrumenter.increment_profile_failure_of("Profile 1") 127 | end 128 | 129 | find("body").native.send_keys(:escape) 130 | click_link("Sail dashboard") 131 | 132 | find("#btn-profiles").click 133 | 134 | within("#profiles-modal") do 135 | expect(page).to have_content("1 error") 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /spec/features/quick_guide_feature_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | feature "quick guide", js: true, type: :feature do 4 | before do 5 | visit "/sail" 6 | end 7 | 8 | it "displays reference on how to use the dashboard" do 9 | expect(page).to have_no_css("#guide-modal", visible: true) 10 | click_button("Guide") 11 | expect(page).to have_css("#guide-modal", visible: true) 12 | 13 | within("#guide-modal") do 14 | expect(page).to have_content("Searching") 15 | expect(page).to have_content("Profiles") 16 | expect(page).to have_content("Relevancy Score") 17 | expect(page).to have_content("Available groups and types") 18 | 19 | find("summary", text: "Searching").click 20 | expect(page).to have_content("By the setting name") 21 | expect(page).to have_css("summary", count: 1) 22 | find("summary", text: "Searching").click 23 | 24 | find("summary", text: "Profiles").click 25 | expect(page).to have_content("Profiles can be used") 26 | expect(page).to have_css("summary", count: 1) 27 | find("summary", text: "Profiles").click 28 | 29 | find("summary", text: "Relevancy Score").click 30 | expect(page).to have_content("Settings have a number on") 31 | expect(page).to have_css("summary", count: 1) 32 | find("summary", text: "Relevancy Score").click 33 | 34 | find("summary", text: "Available groups and types").click 35 | expect(page).to have_content("The cast types currently") 36 | expect(page).to have_css("summary", count: 1) 37 | end 38 | 39 | find("body").native.send_keys(:escape) 40 | expect(page).to have_no_css("#guide-modal", visible: true) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/features/relevancy_score_feature_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | feature "relevancy score", js: true, type: :feature do 4 | let!(:setting_1) do 5 | Sail::Setting.create(name: :setting, cast_type: :string, 6 | value: :something, 7 | description: "Setting that does something", 8 | group: "feature_flags") 9 | end 10 | 11 | let!(:setting_2) do 12 | Sail::Setting.create(name: :configuration, cast_type: :string, 13 | value: :something, 14 | description: "Setting that does something else", 15 | group: "feature_flags") 16 | end 17 | 18 | before do 19 | ActionController::Base.new.expire_fragment(/name: "#{setting_1.name}"/) 20 | ActionController::Base.new.expire_fragment(/name: "#{setting_2.name}"/) 21 | Sail.instrumenter.instance_variable_set(:@number_of_settings, Sail::Setting.count) 22 | Sail.instrumenter.instance_variable_set(:@statistics, { settings: {}, profiles: {} }.with_indifferent_access) 23 | visit "/sail" 24 | end 25 | 26 | it "displays relevancy score in each setting" do 27 | cards = all(".relevancy-score") 28 | 29 | expect(cards[0]).to have_content("0.0") 30 | expect(cards[1]).to have_content("0.0") 31 | 32 | 500.times { Sail.get(:setting) } 33 | 1000.times { Sail.get(:configuration) } 34 | 35 | click_link("Sail") 36 | 37 | cards = all(".relevancy-score") 38 | 39 | expect(cards[0]).to have_content("16.7") 40 | expect(cards[1]).to have_content("33.3") 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/features/resetting_settings_feature_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | feature "resetting settings", js: true, type: :feature do 4 | let!(:setting) do 5 | allow(Sail::Setting).to receive(:config_file_path).and_return(Rails.root.join("config/sail.yml")) 6 | 7 | Sail::Setting.create!(name: name, cast_type: cast_type, 8 | value: setting_value, 9 | description: "Setting that does something", 10 | group: "feature_flags") 11 | end 12 | 13 | before do 14 | visit "/sail" 15 | within(".card") { find(".refresh-button").click } 16 | end 17 | 18 | context "for a non boolean setting" do 19 | let(:cast_type) { :integer } 20 | let(:setting_value) { "3" } 21 | let(:name) { "number_of_parallel_jobs" } 22 | 23 | it "resets value and refreshes input" do 24 | expect(find(".submit-container")).to have_css(".success") 25 | 26 | within(".card") do 27 | expect(find("#input_for_#{setting.name}", visible: false)[:value]).to eq("2") 28 | end 29 | 30 | expect(setting.reload.value).to eq("2") 31 | end 32 | end 33 | 34 | context "for a boolean setting" do 35 | let(:cast_type) { :boolean } 36 | let(:setting_value) { "false" } 37 | let(:name) { "enable_awesome_feature" } 38 | 39 | it "resets value and refreshes input" do 40 | expect(find(".submit-container")).to have_css(".success") 41 | expect(setting.reload.value).to eq("true") 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/features/searching_settings_feature_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | feature "searching settings", js: true, type: :feature do 4 | let!(:setting_1) do 5 | Sail::Setting.create(name: :setting, cast_type: :string, 6 | value: :something, 7 | description: "Setting that does something", 8 | group: "feature_flags", 9 | updated_at: 75.days.ago) 10 | end 11 | 12 | let!(:setting_2) do 13 | Sail::Setting.create(name: :configuration, cast_type: :string, 14 | value: :something, 15 | description: "Setting that does something else", 16 | group: "feature_flags", 17 | updated_at: 15.days.ago) 18 | end 19 | 20 | before do 21 | visit "/sail" 22 | fill_in("query", with: query) 23 | end 24 | 25 | context "using submit via enter" do 26 | before { find("#query").native.send_keys(:return) } 27 | 28 | context "when name matches a setting" do 29 | let(:query) { setting_1.name } 30 | 31 | it "displays the found setting" do 32 | within(".card") do 33 | expect_setting(setting_1) 34 | end 35 | end 36 | end 37 | 38 | context "when searching by group" do 39 | let(:query) { "feature_flags" } 40 | 41 | it "displays all found settings for group" do 42 | expect_setting(setting_1) 43 | expect_setting(setting_2) 44 | end 45 | end 46 | 47 | context "when searching by stale" do 48 | let(:query) { "stale" } 49 | 50 | it "displays all stale settings" do 51 | expect_setting(setting_1) 52 | end 53 | end 54 | 55 | context "when searching by recent" do 56 | let(:query) { "recent 380" } 57 | 58 | it "displays all found settings for group" do 59 | expect_setting(setting_2) 60 | end 61 | end 62 | 63 | context "when name does not match any setting" do 64 | let(:query) { "whatever" } 65 | 66 | it "displays no settings found" do 67 | within("#settings-container") do 68 | expect(page).to have_text("No settings found") 69 | end 70 | end 71 | end 72 | end 73 | 74 | context "using auto submit" do 75 | let(:query) { "feature_flags" } 76 | 77 | it "searches without clicking enter" do 78 | sleep 3 79 | expect_setting(setting_1) 80 | expect_setting(setting_2) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/features/securing_dashboard_access_feature_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | feature "securing dashboard access", js: true, type: :feature do 4 | before do 5 | dashboard_auth_lambda = -> { redirect_to("/") unless current_user.admin? } 6 | Sail::SettingsController.before_action(*dashboard_auth_lambda) 7 | 8 | Sail::Setting.create!(name: "setting", cast_type: :string, 9 | value: :something, 10 | description: "Setting that does something", 11 | group: "feature_flags") 12 | end 13 | 14 | context "when user is admin" do 15 | it "can navigate to Sail" do 16 | visit "/sail" 17 | expect(page).to have_text("Setting") 18 | end 19 | end 20 | 21 | context "when user is not admin" do 22 | it "is redirect to root path" do 23 | user = User.new 24 | 25 | def user.admin? 26 | false 27 | end 28 | 29 | allow_any_instance_of(Sail::SettingsController).to receive(:current_user).and_return(user) 30 | 31 | visit "/sail" 32 | expect(page).to have_text("Inside dummy app") 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/features/sorting_settings_feature_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | feature "sorting settings", js: true, type: :feature do 4 | let!(:setting_1) do 5 | Sail::Setting.create!(name: "Bca", 6 | cast_type: :integer, 7 | value: "5", 8 | description: "Setting that does something", 9 | group: "tuners", 10 | updated_at: 75.days.ago) 11 | end 12 | 13 | let!(:setting_2) do 14 | Sail::Setting.create!(name: "Abc", 15 | cast_type: :string, 16 | value: "A string", 17 | description: "Setting that does something", 18 | group: "general", 19 | updated_at: 15.days.ago) 20 | end 21 | 22 | let!(:setting_3) do 23 | Sail::Setting.create!(name: "Acb", 24 | cast_type: :boolean, 25 | value: "true", 26 | description: "Setting that does something", 27 | group: "feature_flags", 28 | updated_at: 30.days.ago) 29 | end 30 | 31 | let(:settings) { [setting_1, setting_2, setting_3] } 32 | 33 | before do 34 | visit "/sail" 35 | find("#btn-order").click 36 | 37 | within("#sort-menu") do 38 | click_on(sorting_field) 39 | end 40 | end 41 | 42 | [ 43 | { field: "name", order: [0, 2, 1] }, 44 | { field: "updated_at", order: [1, 2, 0] }, 45 | { field: "cast_type", order: [2, 1, 0] }, 46 | { field: "group", order: [0, 1, 2] } 47 | ].each do |info| 48 | context "when sorting field is #{info[:field]}" do 49 | let(:sorting_field) { info[:field] } 50 | 51 | it "orders settings by #{info[:field]}" do 52 | cards = all(".card") 53 | 54 | within(cards[0]) do 55 | expect_setting(settings[info[:order][0]]) 56 | end 57 | 58 | within(cards[1]) do 59 | expect_setting(settings[info[:order][1]]) 60 | end 61 | 62 | within(cards[2]) do 63 | expect_setting(settings[info[:order][2]]) 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/features/viewing_settings_feature_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | feature "viewing settings", js: true, type: :feature do 4 | before do 5 | 20.times do |index| 6 | Sail::Setting.create!(name: "setting_#{index}", cast_type: :string, 7 | value: :something, 8 | description: "Setting that does something", 9 | group: "feature_flags") 10 | end 11 | 12 | 5.times do |index| 13 | Sail::Setting.create!(name: "setting_#{index + 20}", cast_type: :integer, 14 | value: "5", 15 | description: "Setting that does something", 16 | group: "tuners", 17 | updated_at: 75.days.ago) 18 | end 19 | end 20 | 21 | it "displays setting information and allows navigation" do 22 | visit "/sail" 23 | 24 | Sail::Setting.first(20).each { |setting| expect_setting(setting) } 25 | 26 | within("#pagination") do 27 | click_link("2") 28 | end 29 | 30 | Sail::Setting.last(5).each { |setting| expect_setting(setting) } 31 | 32 | find("#angle-left-link").click 33 | Sail::Setting.first(20).each { |setting| expect_setting(setting) } 34 | 35 | find("#angle-right-link").click 36 | Sail::Setting.last(5).each { |setting| expect_setting(setting) } 37 | end 38 | 39 | it "allows clicking on groups to filter" do 40 | visit "/sail" 41 | 42 | within(all(".card").first) do 43 | click_link("feature_flags") 44 | end 45 | 46 | expect(page).to have_css(".card", count: 20) 47 | end 48 | 49 | it "allows clicking on types to filter" do 50 | visit "/sail" 51 | 52 | within(all(".card").first) do 53 | click_link("string") 54 | end 55 | 56 | expect(page).to have_css(".card", count: 20) 57 | end 58 | 59 | it "allows clicking on stale to filter" do 60 | visit "/sail" 61 | find("#angle-right-link").click 62 | 63 | within(all(".card").last) do 64 | click_link("stale") 65 | end 66 | 67 | expect(page).to have_css(".card", count: 5) 68 | end 69 | 70 | it "has a main app link" do 71 | visit "/sail" 72 | 73 | expect(page).to have_css("#main-app-link", count: 1) 74 | find("#main-app-link").click 75 | expect(page).to have_text("Inside dummy app") 76 | end 77 | 78 | it "displays relevancy score" do 79 | Sail.instrumenter.instance_variable_set(:@number_of_settings, Sail::Setting.count) 80 | Sail.instrumenter.instance_variable_set(:@statistics, { settings: {}, profiles: {} }.with_indifferent_access) 81 | 2.times { Sail.get("setting_0") } 82 | 2.times { Sail.get("setting_1") } 83 | 84 | visit "/sail" 85 | 86 | within(all(".card").first) do 87 | expect(page).to have_content("2.0") 88 | end 89 | end 90 | 91 | context "when setting has no group" do 92 | before do 93 | Sail::Setting.first.update!(group: nil) 94 | visit "/sail" 95 | end 96 | 97 | it "does not display group label" do 98 | within(all(".card").first) do 99 | expect(page).to have_no_css(".group-label", count: 1) 100 | expect(page).to have_no_content("feature_flags") 101 | end 102 | end 103 | end 104 | 105 | it "adjusts pagination if there are too many settings" do 106 | 120.times do |index| 107 | Sail::Setting.create!(name: "new_setting_#{index}", cast_type: :string, 108 | value: :something, 109 | description: "Setting that does something", 110 | group: "feature_flags") 111 | end 112 | 113 | visit "/sail" 114 | 115 | within("#pagination") do 116 | expect(page).to have_content("1 2 3 4 5 ●●● 8") 117 | click_link("8") 118 | end 119 | 120 | within("#pagination") do 121 | expect(page).to have_content("1 ●●● 3 4 5 6 7 8") 122 | end 123 | end 124 | 125 | it "shows description when clicking on title" do 126 | visit "/sail" 127 | 128 | setting = Sail::Setting.first 129 | first_card = all(".card").first 130 | 131 | expect_setting(setting) 132 | 133 | first_card.all("h3").first.click 134 | expect(first_card).to have_content(setting.description.capitalize) 135 | 136 | first_card.all("h3").last.click 137 | expect_setting(setting) 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /spec/helpers/sail/application_helper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::ApplicationHelper, type: :helper do 4 | include described_class 5 | 6 | describe "#main_app" do 7 | subject { main_app } 8 | 9 | it "returns the URL helper for the main application" do 10 | expect(subject).to respond_to(:root_path) 11 | end 12 | end 13 | 14 | describe "#formatted_date" do 15 | subject { formatted_date(setting) } 16 | let!(:setting) { Sail::Setting.create(name: "setting", cast_type: :date, value: "2019-01-01 10:00") } 17 | 18 | it "formats date for input" do 19 | expect(subject).to eq("2019-01-01T10:01:00") 20 | end 21 | end 22 | 23 | describe "#settings_container_class" do 24 | subject { settings_container_class(pages) } 25 | 26 | context "when there's a page or more" do 27 | let(:pages) { 1 } 28 | it { is_expected.to eq("") } 29 | end 30 | 31 | context "when there are no pages" do 32 | let(:pages) { 0 } 33 | it { is_expected.to eq("empty") } 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/lib/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::Configuration, type: :lib do 4 | describe ".initialize" do 5 | subject { described_class.new } 6 | 7 | it "assigns the proper default values to configs" do 8 | expect(subject.instance_variable_get(:@cache_life_span)).to eq(6.hours) 9 | expect(subject.instance_variable_get(:@array_separator)).to eq(";") 10 | expect(subject.instance_variable_get(:@dashboard_auth_lambda)).to be_nil 11 | expect(subject.instance_variable_get(:@back_link_path)).to eq("root_path") 12 | expect(subject.instance_variable_get(:@enable_search_auto_submit)).to be_truthy 13 | expect(subject.instance_variable_get(:@days_until_stale)).to eq(60) 14 | expect(subject.instance_variable_get(:@enable_logging)).to be_truthy 15 | expect(subject.instance_variable_get(:@failures_until_reset)).to eq(50) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/lib/false_class_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe FalseClass, type: :lib do 4 | describe "#to_s" do 5 | subject { false.to_s } 6 | it { is_expected.to eq("false") } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/lib/instrumenter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::Instrumenter, type: :lib do 4 | describe ".initialize" do 5 | subject { described_class.new } 6 | 7 | it "sets default instrumenter values" do 8 | expect(subject.instance_variable_get(:@statistics)).to eq("settings" => {}, "profiles" => {}) 9 | end 10 | end 11 | 12 | describe "#increment_usage_of" do 13 | subject { instrumenter.increment_usage_of("setting") } 14 | let(:instrumenter) { described_class.new } 15 | 16 | it "increments usage count" do 17 | expect(instrumenter.instance_variable_get(:@statistics)).to eq("settings" => {}, "profiles" => {}) 18 | 19 | subject 20 | expect(instrumenter.instance_variable_get(:@statistics)).to eq("settings" => { "setting" => { "usages" => 1, "failures" => 0 } }, 21 | "profiles" => {}) 22 | end 23 | end 24 | 25 | describe "#relative_usage_of" do 26 | subject { instrumenter.relative_usage_of("setting") } 27 | let(:instrumenter) { described_class.new } 28 | 29 | before do 30 | instrumenter.increment_usage_of("setting") 31 | instrumenter.increment_usage_of("setting") 32 | instrumenter.increment_usage_of("setting_2") 33 | instrumenter.increment_usage_of("setting_2") 34 | end 35 | 36 | it "calculates percentage ratio of usage" do 37 | expect(subject).to eq(50.0) 38 | end 39 | 40 | context "when statistics are still empty" do 41 | before do 42 | instrumenter.instance_variable_set(:@statistics, { settings: {}, profiles: {} }.with_indifferent_access) 43 | end 44 | 45 | it { is_expected.to be_zero } 46 | end 47 | end 48 | 49 | describe "#increment_failure_of" do 50 | subject { instrumenter.increment_failure_of("setting") } 51 | let(:instrumenter) { described_class.new } 52 | 53 | it "increments failure count" do 54 | expect(instrumenter.instance_variable_get(:@statistics)).to eq("settings" => {}, "profiles" => {}) 55 | 56 | subject 57 | expect(instrumenter.instance_variable_get(:@statistics)).to eq("settings" => { "setting" => { "usages" => 0, "failures" => 1 } }, 58 | "profiles" => {}) 59 | end 60 | 61 | it "resets setting after 50 failures" do 62 | expect(Sail).to receive(:reset).with("setting") 63 | 64 | 51.times { instrumenter.increment_failure_of("setting") } 65 | end 66 | 67 | context "when there is an active profile" do 68 | before { Sail::Profile.create!(name: "profile", active: true) } 69 | 70 | it "increments profile failure count as well" do 71 | expect(instrumenter.instance_variable_get(:@statistics)).to eq("settings" => {}, "profiles" => {}) 72 | 73 | subject 74 | expect(instrumenter.instance_variable_get(:@statistics)).to eq("settings" => { "setting" => { "usages" => 0, "failures" => 1 } }, 75 | "profiles" => { "profile" => 1 }) 76 | end 77 | end 78 | end 79 | 80 | describe "#increment_profile_failure_of" do 81 | subject { instrumenter.increment_profile_failure_of("profile") } 82 | let(:instrumenter) { described_class.new } 83 | 84 | it "increments failure count" do 85 | expect(instrumenter.instance_variable_get(:@statistics)).to eq("settings" => {}, "profiles" => {}) 86 | 87 | subject 88 | expect(instrumenter.instance_variable_get(:@statistics)).to eq("settings" => {}, 89 | "profiles" => { "profile" => 1 }) 90 | end 91 | end 92 | 93 | describe "#profile" do 94 | let(:instrumenter) { described_class.new } 95 | 96 | it "returns profile information" do 97 | expect(instrumenter.profile("profile")).to be_zero 98 | instrumenter.increment_profile_failure_of("profile") 99 | expect(instrumenter.profile("profile")).to eq(1) 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /spec/lib/true_class_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe TrueClass, type: :lib do 4 | describe "#to_s" do 5 | subject { true.to_s } 6 | it { is_expected.to eq("true") } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/lib/types/ab_test_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::Types::AbTest, type: :lib do 4 | describe "#to_value" do 5 | subject(:to_value) { described_class.new(setting).to_value } 6 | 7 | let(:setting) { Sail::Setting.create!(name: :setting, cast_type: :ab_test, value: value) } 8 | 9 | context "when value is true" do 10 | let(:value) { "true" } 11 | it { expect([true, false]).to include(to_value) } 12 | end 13 | 14 | context "when value is false" do 15 | let(:value) { "false" } 16 | it { is_expected.to be_falsey } 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/lib/types/array_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::Types::Array, type: :lib do 4 | let(:setting) { Sail::Setting.create!(name: :setting, cast_type: :array, value: "1;2;3") } 5 | 6 | describe "#to_value" do 7 | subject { described_class.new(setting).to_value } 8 | it { is_expected.to eq(%w[1 2 3]) } 9 | end 10 | 11 | describe "#from" do 12 | subject { described_class.new(setting).from(%w[1 2 3]) } 13 | it { is_expected.to eq("1;2;3") } 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/lib/types/boolean_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::Types::Boolean, type: :lib do 4 | let(:setting) { Sail::Setting.create!(name: :setting, cast_type: :boolean, value: "true") } 5 | 6 | describe "#to_value" do 7 | subject { described_class.new(setting).to_value } 8 | it { is_expected.to eq(true) } 9 | end 10 | 11 | describe "#from" do 12 | subject { described_class.new(setting).from(value) } 13 | 14 | context "when value is a Boolean as a string" do 15 | let(:value) { "true" } 16 | it { is_expected.to eq("true") } 17 | end 18 | 19 | context "when value is the string on" do 20 | let(:value) { "on" } 21 | it { is_expected.to eq("true") } 22 | end 23 | 24 | context "when value is a Boolean" do 25 | let(:value) { true } 26 | it { is_expected.to eq("true") } 27 | end 28 | 29 | context "when value is nil" do 30 | let(:value) { nil } 31 | it { is_expected.to eq("false") } 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/lib/types/cron_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::Types::Cron, type: :lib do 4 | describe "#to_value" do 5 | subject { described_class.new(setting).to_value } 6 | 7 | let(:setting) { Sail::Setting.create!(name: :setting, cast_type: :cron, value: "0 * * * *") } 8 | 9 | before do 10 | allow(DateTime).to receive(:now).and_return(DateTime.parse(date_string).utc) 11 | end 12 | 13 | context "when cron matches" do 14 | let(:date_string) { "2018-10-05 20:00" } 15 | it { is_expected.to be_truthy } 16 | end 17 | 18 | context "when cron does not match" do 19 | let(:date_string) { "2018-10-05 20:02" } 20 | it { is_expected.to be_falsey } 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/lib/types/date_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::Types::Date, type: :lib do 4 | describe "#to_value" do 5 | subject { described_class.new(setting).to_value } 6 | 7 | let(:setting) { Sail::Setting.create!(name: :setting, cast_type: :date, value: "2019-01-01 10:00") } 8 | 9 | it { is_expected.to eq(DateTime.parse("2019-01-01 10:00").utc) } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/lib/types/float_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::Types::Float, type: :lib do 4 | describe "#to_value" do 5 | subject { described_class.new(setting).to_value } 6 | 7 | let(:setting) { Sail::Setting.create!(name: :setting, cast_type: :float, value: "3.123") } 8 | 9 | it { is_expected.to eq(3.123) } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/lib/types/integer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::Types::Integer, type: :lib do 4 | describe "#to_value" do 5 | subject { described_class.new(setting).to_value } 6 | 7 | let(:setting) { Sail::Setting.create!(name: :setting, cast_type: :integer, value: "5") } 8 | 9 | it { is_expected.to eq(5) } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/lib/types/locales_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::Types::Locales, type: :lib do 4 | let(:setting) { Sail::Setting.create!(name: :setting, cast_type: :array, value: "en;es") } 5 | 6 | describe "#to_value" do 7 | subject do 8 | I18n.with_locale(locale) do 9 | described_class.new(setting).to_value 10 | end 11 | end 12 | 13 | context "when locale is included" do 14 | let(:locale) { :en } 15 | it { is_expected.to be_truthy } 16 | end 17 | 18 | context "when locale is not included" do 19 | let(:locale) { :fr } 20 | it { is_expected.to be_falsey } 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/lib/types/obj_model_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::Types::ObjModel, type: :lib do 4 | describe "#to_value" do 5 | subject { described_class.new(setting).to_value } 6 | 7 | let(:setting) { Sail::Setting.create!(name: :setting, cast_type: :obj_model, value: "Test") } 8 | 9 | it { is_expected.to eq(Test) } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/lib/types/set_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::Types::Set, type: :lib do 4 | let(:setting) { Sail::Setting.create!(name: :setting, cast_type: :set, value: "1;2;3") } 5 | 6 | describe "#to_value" do 7 | subject { described_class.new(setting).to_value } 8 | it { is_expected.to eq(Set["1", "2", "3"]) } 9 | end 10 | 11 | describe "#from" do 12 | subject { described_class.new(setting).from(%w[1 2 3]) } 13 | it { is_expected.to eq("1;2;3") } 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/lib/types/throttle_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::Types::Throttle, type: :lib do 4 | describe "#to_value" do 5 | subject(:to_value) { described_class.new(setting).to_value } 6 | 7 | let(:setting) { Sail::Setting.create!(name: :setting, cast_type: :throttle, value: "30") } 8 | 9 | it { expect([true, false]).to include(to_value) } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/lib/types/type_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::Types::Type, type: :lib do 4 | let(:setting) { Sail::Setting.create!(name: :setting, cast_type: :string, value: "30") } 5 | 6 | describe "#to_value" do 7 | subject { described_class.new(setting).to_value } 8 | it { is_expected.to eq("30") } 9 | end 10 | 11 | describe "#from" do 12 | subject { described_class.new(setting).from("50") } 13 | it { is_expected.to eq("50") } 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/lib/types/uri_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::Types::Uri, type: :lib do 4 | describe "#to_value" do 5 | subject { described_class.new(setting).to_value } 6 | 7 | let(:setting) { Sail::Setting.create!(name: :setting, cast_type: :uri, value: "https://google.com") } 8 | 9 | it { is_expected.to eq(URI.parse("https://google.com")) } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/models/sail/entry_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::Entry, type: :model do 4 | describe "scopes" do 5 | describe ".by_profile_name" do 6 | subject { described_class.by_profile_name(:profile) } 7 | 8 | let!(:profile_1) { Sail::Profile.create!(name: :profile) } 9 | let!(:setting_1) { Sail::Setting.create!(name: :setting, cast_type: :integer, value: 1) } 10 | let!(:entry_1) { Sail::Entry.create!(setting: setting_1, profile: profile_1, value: 2) } 11 | 12 | let!(:profile_2) { Sail::Profile.create!(name: :profile_2) } 13 | let!(:setting_2) { Sail::Setting.create!(name: :setting_2, cast_type: :integer, value: 1) } 14 | let!(:entry_2) { Sail::Entry.create!(setting: setting_2, profile: profile_2, value: 5) } 15 | 16 | it { is_expected.to include(entry_1) } 17 | it { is_expected.to_not include(entry_2) } 18 | end 19 | end 20 | 21 | describe "#dirty?" do 22 | subject { entry.dirty? } 23 | 24 | let!(:profile) { Sail::Profile.create!(name: :profile) } 25 | let!(:setting) { Sail::Setting.create!(name: :setting, cast_type: :integer, value: setting_value) } 26 | let!(:entry) { Sail::Entry.create!(setting: setting, profile: profile, value: 1) } 27 | 28 | context "when setting and entry values match" do 29 | let(:setting_value) { 1 } 30 | it { is_expected.to be_falsey } 31 | end 32 | 33 | context "when setting and entry values do not match" do 34 | let(:setting_value) { 2 } 35 | it { is_expected.to be_truthy } 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/models/sail/profile_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail::Profile, type: :model do 4 | let!(:setting_1) { Sail::Setting.create!(name: :setting_1, cast_type: :integer, value: 1) } 5 | let!(:setting_2) { Sail::Setting.create!(name: :setting_2, cast_type: :integer, value: 2) } 6 | 7 | describe "callbacks" do 8 | describe "destroy" do 9 | subject(:destroy) { profile.destroy } 10 | 11 | let!(:profile) { described_class.create_or_update_self(:profile).first } 12 | 13 | it "destroys entries as well" do 14 | expect { destroy }.to change(Sail::Entry, :count).by(-2) 15 | end 16 | end 17 | end 18 | 19 | describe "validations" do 20 | describe "only_one_active_profile" do 21 | it "prevents two or more active profiles" do 22 | described_class.create_or_update_self(:profile) 23 | profile = described_class.new(name: "new_profile", active: true) 24 | expect(profile).to be_invalid 25 | end 26 | 27 | it "allows regular profile activation" do 28 | profile = described_class.new(name: "new_profile", active: true) 29 | expect(profile).to be_valid 30 | end 31 | end 32 | end 33 | 34 | describe ".create_or_update_self" do 35 | subject(:create_or_update_self) { described_class.create_or_update_self(:profile) } 36 | 37 | it "creates entries for each setting" do 38 | expect { create_or_update_self }.to change(Sail::Entry, :count).by(2) 39 | end 40 | 41 | it "creates the profile" do 42 | expect { create_or_update_self }.to change(Sail::Profile, :count).by(1) 43 | end 44 | 45 | it "saves entries with current setting values" do 46 | create_or_update_self 47 | expect(Sail::Entry.find_by(setting: setting_1).value).to eq(setting_1.value) 48 | expect(Sail::Entry.find_by(setting: setting_2).value).to eq(setting_2.value) 49 | end 50 | 51 | it "updates values if profile already exists" do 52 | create_or_update_self 53 | expect(setting_1.entries.first.value).to eq("1") 54 | setting_1.update!(value: "5") 55 | 56 | expect { described_class.create_or_update_self(:profile) }.to change(Sail::Profile, :count).by(0) 57 | expect(setting_1.entries.reload.first.value).to eq("5") 58 | end 59 | end 60 | 61 | describe ".switch" do 62 | subject(:switch) { described_class.switch(:profile_1) } 63 | 64 | let(:profile_1) { Sail::Profile.find_by(name: :profile_1) } 65 | let(:profile_2) { Sail::Profile.find_by(name: :profile_2) } 66 | 67 | before do 68 | Sail::Profile.create_or_update_self(:profile_1) 69 | Sail.set(:setting_1, 3) 70 | Sail.set(:setting_2, 5) 71 | Sail::Profile.create_or_update_self(:profile_2) 72 | end 73 | 74 | it "switches between two profiles" do 75 | expect(profile_1.active).to be_falsey 76 | expect(profile_2.active).to be_truthy 77 | expect(setting_1.reload.value).to eq("3") 78 | expect(setting_2.reload.value).to eq("5") 79 | switch 80 | expect(setting_1.reload.value).to eq("1") 81 | expect(setting_2.reload.value).to eq("2") 82 | expect(profile_1.reload.active).to be_truthy 83 | expect(profile_2.reload.active).to be_falsey 84 | end 85 | end 86 | 87 | describe "#dirty?" do 88 | subject { profile.dirty? } 89 | 90 | let!(:profile) { Sail::Profile.create_or_update_self(:profile).first } 91 | 92 | it { is_expected.to be_falsey } 93 | 94 | context "when a setting has been changed" do 95 | before do 96 | Sail.set(:setting_1, 3) 97 | profile.reload 98 | end 99 | 100 | it { is_expected.to be_truthy } 101 | end 102 | end 103 | 104 | describe ".current" do 105 | let!(:profile) { Sail::Profile.create_or_update_self(:profile).first } 106 | let!(:profile_2) { Sail::Profile.create_or_update_self(:profile_2).first } 107 | 108 | it { expect(described_class.current).to eq(profile_2) } 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /spec/sail_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Sail, type: :lib do 4 | describe ".get" do 5 | subject { described_class.get("name") } 6 | 7 | it "delegates to setting" do 8 | expect(Sail::Setting).to receive(:get).with("name") 9 | subject 10 | end 11 | 12 | it "allows using settings in block format" do 13 | allow(Sail::Setting).to receive(:get).with("name").and_return("something") 14 | 15 | described_class.get("name") do |setting_value| 16 | expect(setting_value).to eq("something") 17 | end 18 | end 19 | 20 | it "returns the value of the evaluated block" do 21 | allow(Sail::Setting).to receive(:get).with("name").and_return(5) 22 | 23 | result = described_class.get("name") do |setting_value| 24 | setting_value * 5 25 | end 26 | 27 | expect(result).to eq(25) 28 | end 29 | 30 | context "when passing expected errors" do 31 | subject { described_class.get("name", expected_errors: [ArgumentError]) } 32 | 33 | context "and the expected error occurs" do 34 | before do 35 | allow(Sail::Setting).to receive(:get).with("name").and_raise(ArgumentError) 36 | end 37 | 38 | it "does not increment failure count" do 39 | expect_any_instance_of(Sail::Instrumenter).not_to receive(:increment_failure_of).with("name") 40 | 41 | expect { subject }.to raise_error(ArgumentError) 42 | end 43 | end 44 | 45 | context "and an unexpected error occurs" do 46 | before do 47 | allow(Sail::Setting).to receive(:get).with("name").and_raise(StandardError) 48 | end 49 | 50 | it "increments failure count" do 51 | expect_any_instance_of(Sail::Instrumenter).to receive(:increment_failure_of).with("name") 52 | 53 | expect { subject }.to raise_error(StandardError) 54 | end 55 | end 56 | 57 | context "when not passing expected errors but an error occurs" do 58 | subject { described_class.get("name") } 59 | 60 | before do 61 | allow(Sail::Setting).to receive(:get).with("name").and_raise(StandardError) 62 | end 63 | 64 | it "does not increment failure count" do 65 | expect_any_instance_of(Sail::Instrumenter).not_to receive(:increment_failure_of).with("name") 66 | 67 | expect { subject }.to raise_error(StandardError) 68 | end 69 | end 70 | end 71 | end 72 | 73 | describe ".set" do 74 | subject { described_class.set("name", "value") } 75 | 76 | it "delegates to setting" do 77 | expect(Sail::Setting).to receive(:set).with("name", "value") 78 | subject 79 | end 80 | end 81 | 82 | describe ".switcher" do 83 | subject { described_class.switcher(positive: "positive", negative: "negative", throttled_by: "throttle") } 84 | 85 | it "delegates to setting" do 86 | expect(Sail::Setting).to receive(:switcher).with(positive: "positive", negative: "negative", throttled_by: "throttle") 87 | subject 88 | end 89 | end 90 | 91 | describe ".reset" do 92 | subject { described_class.reset("name") } 93 | 94 | it "delegates to setting" do 95 | expect(Sail::Setting).to receive(:reset).with("name") 96 | subject 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV["RAILS_ENV"] = "test" 4 | require File.expand_path("dummy/config/environment.rb", __dir__) 5 | require "rspec/rails" 6 | require "rails/all" 7 | require "database_cleaner" 8 | require "capybara/rspec" 9 | require "capybara/rails" 10 | require "sail" 11 | require "selenium/webdriver" 12 | require "webdrivers/geckodriver" 13 | require "rspec/retry" 14 | 15 | DatabaseCleaner.strategy = :truncation 16 | 17 | class User 18 | def admin? 19 | true 20 | end 21 | 22 | def id 23 | 1 24 | end 25 | end 26 | 27 | class WardenObject 28 | def user 29 | User.new 30 | end 31 | end 32 | 33 | RSpec.configure do |config| 34 | config.expect_with :rspec do |c| 35 | c.syntax = :expect 36 | end 37 | 38 | config.infer_spec_type_from_file_location! 39 | config.order = :random 40 | 41 | config.before(:each) do 42 | DatabaseCleaner.clean 43 | end 44 | 45 | config.before(:each, type: :controller) do 46 | controller.request.env["warden"] = WardenObject.new 47 | end 48 | 49 | config.before(:each, type: :feature) do 50 | allow_any_instance_of(Sail::ApplicationController).to receive(:current_user).and_return(User.new) 51 | end 52 | 53 | config.around :each, :js do |ex| 54 | ex.run_with_retry retry: 3 55 | end 56 | 57 | Capybara.javascript_driver = :selenium_headless 58 | Capybara.server = :webrick 59 | Capybara.default_max_wait_time = 5 60 | Webdrivers.install_dir = "~/bin/firefox_driver" if ENV["ON_CI"].present? 61 | end 62 | 63 | # rubocop:disable Metrics/AbcSize 64 | def expect_setting(setting) 65 | expect(page).to have_text(setting.name.titleize) 66 | expect(page).to have_text(setting.cast_type) 67 | expect(page).to have_link(setting.group) 68 | expect(page).to have_button("SAVE", disabled: true) 69 | 70 | if setting.boolean? || setting.ab_test? 71 | expect(page).to have_css(".slider") 72 | else 73 | expect(page).to have_field("value") 74 | end 75 | end 76 | # rubocop:enable Metrics/AbcSize 77 | --------------------------------------------------------------------------------