rails-feature-flip 0.1.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +25 -0
- data/CHANGELOG.md +37 -3
- data/CODE_OF_CONDUCT.md +1 -1
- data/README.md +32 -2
- data/Rakefile +9 -6
- data/lib/generators/rails_feature_flip/feature/USAGE +18 -6
- data/lib/generators/rails_feature_flip/feature/feature_generator.rb +57 -7
- data/lib/generators/rails_feature_flip/feature/templates/feature.erb +29 -20
- data/lib/generators/rails_feature_flip/install/install_generator.rb +25 -18
- data/lib/generators/rails_feature_flip/install/templates/all.rb +3 -2
- data/lib/rails_feature_flip/controller_methods.rb +25 -0
- data/lib/rails_feature_flip/helper.rb +20 -0
- data/lib/rails_feature_flip/railtie.rb +24 -0
- data/lib/rails_feature_flip/registry.rb +45 -0
- data/lib/rails_feature_flip/tasks.rake +48 -0
- data/lib/rails_feature_flip/test_helper.rb +45 -0
- data/lib/rails_feature_flip/version.rb +1 -1
- data/lib/rails_feature_flip.rb +25 -0
- metadata +14 -10
- data/rails-feature-flip.gemspec +0 -43
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 31fa37d8001db49630a6166dc989f7df70cd2e93bcd2d808a64ba2891a8ddf25
|
|
4
|
+
data.tar.gz: 00c2471a2ce557a8f3c4f82213ad53e277cc75a92469f575ec9ae50881fc9ea0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 15ccb56aab858a4c5b4f40076c20b922561b6f4b071e9af57fcb719507b7e48bc5905ef88fc7c49c5aa61a295de8f5b6f764502f9e33899b4a86ea70fb23b0f3
|
|
7
|
+
data.tar.gz: fb2cb9a153aac70b04741afcf91f86952ecd53e02a64b09793fef493df90b0e89ab458c5eab725979630d3c87c05b1be1e2fc8df3c5b257b7622bad7b804048f
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
inherit_mode:
|
|
2
|
+
merge:
|
|
3
|
+
- Exclude
|
|
4
|
+
|
|
5
|
+
plugins:
|
|
6
|
+
- rubocop-minitest
|
|
7
|
+
- rubocop-rake
|
|
8
|
+
|
|
9
|
+
AllCops:
|
|
10
|
+
TargetRubyVersion: 3.0
|
|
11
|
+
NewCops: enable
|
|
12
|
+
SuggestExtensions: false
|
|
13
|
+
Exclude:
|
|
14
|
+
- test/tmp/**/*
|
|
15
|
+
- '*.md'
|
|
16
|
+
|
|
17
|
+
Style/Documentation:
|
|
18
|
+
Enabled: false
|
|
19
|
+
|
|
20
|
+
Layout/LineLength:
|
|
21
|
+
Max: 120
|
|
22
|
+
|
|
23
|
+
Metrics/ClassLength:
|
|
24
|
+
Exclude:
|
|
25
|
+
- test/**/*
|
data/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,43 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
-
## [
|
|
3
|
+
## [1.0.0] - 2026-03-29
|
|
4
|
+
|
|
5
|
+
### Breaking Changes
|
|
6
|
+
|
|
7
|
+
- Requires Ruby >= 3.0 (was >= 2.6)
|
|
8
|
+
- Requires Rails >= 7.0 (was >= 5.0)
|
|
9
|
+
- Generated feature classes now use ActiveModel::Attributes instead of hand-rolled attr_reader/initialize
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
- Fix generator namespace typo (`rails_feature_flag` → `rails_feature_flip`) in README, USAGE, and install message
|
|
14
|
+
- Fix `all.rb` loader using fragile relative path — now uses `File.expand_path(__dir__)`
|
|
15
|
+
- Fix singular/plural mismatch in install generator (`config/feature` → `config/features`)
|
|
16
|
+
- Fix install generator using `File.read` with CWD-relative path instead of destination root
|
|
17
|
+
- Use idiomatic `empty_directory` in install generator instead of `Dir.mkdir`
|
|
18
|
+
|
|
19
|
+
### New Features
|
|
4
20
|
|
|
5
|
-
-
|
|
21
|
+
- **ActiveModel::Attributes**: Generated features include type casting, built-in defaults, and attribute introspection
|
|
22
|
+
- **Default values**: `--defaults enabled:false amount:100` flag on the feature generator
|
|
23
|
+
- **Auto-enabled attribute**: `enabled:boolean` is added by default (skip with `--no-enabled`)
|
|
24
|
+
- **Feature namespacing**: `Billing::InvoicePdf` generates nested modules in subdirectories
|
|
25
|
+
- **Controller/view helpers**: `feature_enabled?(:name)` and `require_feature :name` via Railtie
|
|
26
|
+
- **Test helper**: `with_feature(:name, enabled: true) { ... }` for overriding features in tests
|
|
27
|
+
- **Feature registry**: `RailsFeatureFlip::Registry.all`, `.names`, `.find(:name)`
|
|
28
|
+
- **Runtime toggles**: `RailsFeatureFlip.enable(:name)`, `.disable(:name)`, `.toggle(:name)`
|
|
29
|
+
- **Rake task**: `rake features` lists all registered features with status and attributes
|
|
30
|
+
|
|
31
|
+
### Quality
|
|
32
|
+
|
|
33
|
+
- Added Rubocop with rubocop-minitest and rubocop-rake
|
|
34
|
+
- Added 40 tests with 152 assertions covering generators, helpers, registry, and toggles
|
|
35
|
+
- CI matrix expanded to Ruby 3.0, 3.1, 3.2, 3.3, 3.4, 4.0
|
|
6
36
|
|
|
7
37
|
## [0.1.1] - 2024-02-23
|
|
8
38
|
|
|
9
|
-
-
|
|
39
|
+
- Initial release on rubygems.org
|
|
40
|
+
|
|
41
|
+
## [0.1.0] - 2024-02-23
|
|
42
|
+
|
|
43
|
+
- Start of the gem
|
data/CODE_OF_CONDUCT.md
CHANGED
|
@@ -39,7 +39,7 @@ This Code of Conduct applies within all community spaces, and also applies when
|
|
|
39
39
|
|
|
40
40
|
## Enforcement
|
|
41
41
|
|
|
42
|
-
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at ben@
|
|
42
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at ben@bdeutscher.org. All complaints will be reviewed and investigated promptly and fairly.
|
|
43
43
|
|
|
44
44
|
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
|
45
45
|
|
data/README.md
CHANGED
|
@@ -1,8 +1,38 @@
|
|
|
1
1
|
# RailsFeatureFlip
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This gem provides a generator to create feature flip config classes that can be used to define what features can be used in what stage and/or how they should behave in what stage.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
## Example
|
|
7
|
+
|
|
8
|
+
bin/rails generate rails_feature_flip:feature Thing enabled:boolean foo bar
|
|
9
|
+
|
|
10
|
+
This will create:
|
|
11
|
+
config/features/thing_feature.rb
|
|
12
|
+
|
|
13
|
+
Then in application.rb:
|
|
14
|
+
config.x.features.help_center = Features::Thing.new(enabled: false, foo: 'bar', bar: 'baz')
|
|
15
|
+
With that you can use:
|
|
16
|
+
App.features.thing.enabled?
|
|
17
|
+
# => false
|
|
18
|
+
App.features.thing.foo
|
|
19
|
+
# => bar
|
|
20
|
+
|
|
21
|
+
The main benefit over the default `config.x` namespace is the readability in code whereas
|
|
22
|
+
|
|
23
|
+
if App.features.thing.enabled?
|
|
24
|
+
# do something with feature "thing"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
is more readable than
|
|
28
|
+
|
|
29
|
+
if Rails.env.development?
|
|
30
|
+
# do something with feature "thing" in development stage
|
|
31
|
+
elsif Rails.env.test?
|
|
32
|
+
# do something with feature "thing" in test stage
|
|
33
|
+
else
|
|
34
|
+
# do something with feature "thing" in production stage
|
|
35
|
+
end
|
|
6
36
|
|
|
7
37
|
## Installation
|
|
8
38
|
|
data/Rakefile
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
3
|
+
require 'bundler/gem_tasks'
|
|
4
|
+
require 'rake/testtask'
|
|
5
|
+
require 'rubocop/rake_task'
|
|
5
6
|
|
|
6
7
|
Rake::TestTask.new(:test) do |t|
|
|
7
|
-
t.libs <<
|
|
8
|
-
t.libs <<
|
|
9
|
-
t.test_files = FileList[
|
|
8
|
+
t.libs << 'test'
|
|
9
|
+
t.libs << 'lib'
|
|
10
|
+
t.test_files = FileList['test/**/test_*.rb']
|
|
10
11
|
end
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
RuboCop::RakeTask.new
|
|
14
|
+
|
|
15
|
+
task default: %i[test rubocop]
|
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
Description:
|
|
2
|
-
Generates a feature file to manage a feature of the app
|
|
2
|
+
Generates a feature file to manage a feature of the app.
|
|
3
|
+
Uses ActiveModel::Attributes for type casting and defaults.
|
|
3
4
|
|
|
4
5
|
Example:
|
|
5
|
-
bin/rails generate
|
|
6
|
+
bin/rails generate rails_feature_flip:feature Thing max_results:integer
|
|
7
|
+
|
|
8
|
+
With default values:
|
|
9
|
+
bin/rails generate rails_feature_flip:feature Thing max_results:integer --defaults enabled:false max_results:10
|
|
10
|
+
|
|
11
|
+
Namespaced feature (creates config/features/billing/invoice_pdf_feature.rb):
|
|
12
|
+
bin/rails generate rails_feature_flip:feature Billing::InvoicePdf max:integer
|
|
13
|
+
|
|
14
|
+
Without the auto-added enabled attribute:
|
|
15
|
+
bin/rails generate rails_feature_flip:feature Thing foo --no-enabled
|
|
6
16
|
|
|
7
17
|
This will create:
|
|
8
18
|
config/features/thing_feature.rb
|
|
9
19
|
|
|
10
20
|
Then in application.rb:
|
|
11
|
-
config.x.features.
|
|
21
|
+
config.x.features.thing = Features::ThingFeature.new(enabled: true, max_results: 10)
|
|
12
22
|
With that you can use:
|
|
13
23
|
App.features.thing.enabled?
|
|
14
|
-
# =>
|
|
15
|
-
App.features.thing.
|
|
16
|
-
# =>
|
|
24
|
+
# => true
|
|
25
|
+
App.features.thing.max_results
|
|
26
|
+
# => 10
|
|
27
|
+
|
|
28
|
+
Supported types: boolean, integer, float, string (default)
|
|
@@ -6,18 +6,68 @@ module RailsFeatureFlip
|
|
|
6
6
|
class FeatureGenerator < Rails::Generators::NamedBase
|
|
7
7
|
source_root File.expand_path('templates', __dir__)
|
|
8
8
|
|
|
9
|
-
argument :attributes, type: :array, default: [],
|
|
9
|
+
argument :attributes, type: :array, default: [],
|
|
10
|
+
banner: 'config_key[:type] (supported types: boolean, integer, float, string)'
|
|
11
|
+
|
|
12
|
+
class_option :defaults, type: :hash, default: {},
|
|
13
|
+
desc: 'Default values for attributes (e.g. --defaults enabled:false amount:100)'
|
|
14
|
+
class_option :enabled, type: :boolean, default: true,
|
|
15
|
+
desc: 'Add enabled:boolean attribute (use --no-enabled to skip)'
|
|
10
16
|
|
|
11
17
|
# Generate a feature
|
|
12
18
|
def generate_feature
|
|
13
19
|
@name = name.underscore.downcase
|
|
14
|
-
|
|
15
|
-
@
|
|
16
|
-
@
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
parts = class_name.split('::')
|
|
21
|
+
@namespace_parts = parts[0..-2]
|
|
22
|
+
@feature_class_name = "#{parts.last}Feature"
|
|
23
|
+
@attributes = prepend_enabled_attribute(attributes)
|
|
24
|
+
@boolean_attributes = @attributes.select { |attr| attr.type == :boolean }
|
|
25
|
+
@defaults = options[:defaults]
|
|
26
|
+
|
|
27
|
+
template('feature.erb', "config/features/#{@name}_feature.rb")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Prepends enabled:boolean unless --no-enabled or already specified
|
|
33
|
+
def prepend_enabled_attribute(attrs)
|
|
34
|
+
return attrs unless options[:enabled]
|
|
35
|
+
return attrs if attrs.any? { |attr| attr.name == 'enabled' }
|
|
36
|
+
|
|
37
|
+
enabled_attr = Rails::Generators::GeneratedAttribute.parse('enabled:boolean')
|
|
38
|
+
[enabled_attr] + attrs
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Renders an ActiveModel attribute declaration line
|
|
42
|
+
def attribute_declaration(attr)
|
|
43
|
+
type = activemodel_type(attr)
|
|
44
|
+
default = @defaults[attr.name]
|
|
45
|
+
parts = ["attribute :#{attr.name}, :#{type}"]
|
|
46
|
+
parts << "default: #{format_default(type, default)}" if default
|
|
47
|
+
parts.join(', ')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def activemodel_type(attr)
|
|
51
|
+
case attr.type.to_s
|
|
52
|
+
when 'boolean' then 'boolean'
|
|
53
|
+
when 'integer' then 'integer'
|
|
54
|
+
when 'float' then 'float'
|
|
55
|
+
else 'string'
|
|
56
|
+
end
|
|
57
|
+
end
|
|
19
58
|
|
|
20
|
-
|
|
59
|
+
# Formats a default value as valid Ruby for the attribute declaration
|
|
60
|
+
def format_default(type, value)
|
|
61
|
+
case type
|
|
62
|
+
when 'boolean'
|
|
63
|
+
value.to_s == 'true' ? 'true' : 'false'
|
|
64
|
+
when 'integer'
|
|
65
|
+
value.to_s.to_i.to_s
|
|
66
|
+
when 'float'
|
|
67
|
+
value.to_s.to_f.to_s
|
|
68
|
+
else
|
|
69
|
+
"'#{value}'"
|
|
70
|
+
end
|
|
21
71
|
end
|
|
22
72
|
end
|
|
23
73
|
end
|
|
@@ -1,26 +1,35 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'active_model'
|
|
4
|
+
|
|
3
5
|
# Manages features of the application
|
|
4
6
|
module Features
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
<%- @namespace_parts.each_with_index do |ns, i| -%>
|
|
8
|
+
<%= ' ' * (i + 1) %>module <%= ns %>
|
|
9
|
+
<%- end -%>
|
|
10
|
+
<%= ' ' * (@namespace_parts.size + 1) %># Settings for the feature <%= @feature_class_name %>
|
|
11
|
+
<%= ' ' * (@namespace_parts.size + 1) %>class <%= @feature_class_name %>
|
|
12
|
+
<%= ' ' * (@namespace_parts.size + 2) %>include ActiveModel::Attributes
|
|
13
|
+
<%= ' ' * (@namespace_parts.size + 2) %>include ActiveModel::AttributeAssignment
|
|
14
|
+
|
|
15
|
+
<%- @attributes.each do |attribute| -%>
|
|
16
|
+
<%= ' ' * (@namespace_parts.size + 2) %><%= attribute_declaration(attribute) %>
|
|
17
|
+
<%- end -%>
|
|
8
18
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@<%= attribute.name %> = <%= attribute.name %>
|
|
16
|
-
<%- end -%>
|
|
17
|
-
end
|
|
19
|
+
<%= ' ' * (@namespace_parts.size + 2) %># @param attrs [Hash] attribute values to initialize with
|
|
20
|
+
<%= ' ' * (@namespace_parts.size + 2) %>def initialize(**attrs)
|
|
21
|
+
<%= ' ' * (@namespace_parts.size + 3) %>super()
|
|
22
|
+
<%= ' ' * (@namespace_parts.size + 3) %>assign_attributes(attrs) unless attrs.empty?
|
|
23
|
+
<%= ' ' * (@namespace_parts.size + 2) %>end
|
|
24
|
+
<%- @boolean_attributes.each do |attribute| -%>
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
end
|
|
26
|
+
<%= ' ' * (@namespace_parts.size + 2) %># ?-method for the attribute <%= attribute.name %>
|
|
27
|
+
<%= ' ' * (@namespace_parts.size + 2) %>def <%= attribute.name %>?
|
|
28
|
+
<%= ' ' * (@namespace_parts.size + 3) %><%= attribute.name %>
|
|
29
|
+
<%= ' ' * (@namespace_parts.size + 2) %>end
|
|
30
|
+
<%- end -%>
|
|
31
|
+
<%= ' ' * (@namespace_parts.size + 1) %>end
|
|
32
|
+
<%- @namespace_parts.size.downto(1) do |i| -%>
|
|
33
|
+
<%= ' ' * i %>end
|
|
34
|
+
<%- end -%>
|
|
35
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'rails/generators'
|
|
2
4
|
|
|
3
5
|
module RailsFeatureFlip
|
|
@@ -11,7 +13,7 @@ module RailsFeatureFlip
|
|
|
11
13
|
|
|
12
14
|
# Creates RAILS_ROOT/config/features folder unless it exists
|
|
13
15
|
def setup_features_dir
|
|
14
|
-
|
|
16
|
+
empty_directory 'config/features'
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
# Copy default loading file to autoload all features inside the features folder
|
|
@@ -21,20 +23,12 @@ module RailsFeatureFlip
|
|
|
21
23
|
|
|
22
24
|
def insert_feature_load_statement
|
|
23
25
|
application_rb = 'config/application.rb'
|
|
24
|
-
|
|
25
|
-
unless
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
# Automatically load all feature configs - you can load the features manually one by one if you want to.
|
|
31
|
-
# Be sure to comment out require_relative 'features/all' when doing so.
|
|
32
|
-
# example:
|
|
33
|
-
# require_relative 'features/foo'
|
|
34
|
-
# require_relative 'feature/bar'
|
|
35
|
-
require_relative 'features/all'
|
|
36
|
-
RUBY
|
|
37
|
-
end
|
|
26
|
+
application_rb_path = File.join(destination_root, application_rb)
|
|
27
|
+
return display_post_install_message unless File.exist?(application_rb_path)
|
|
28
|
+
|
|
29
|
+
content = File.read(application_rb_path)
|
|
30
|
+
unless content.include?("require_relative 'features/all'")
|
|
31
|
+
insert_into_file application_rb, feature_load_snippet, after: /^Bundler.require.*$/
|
|
38
32
|
end
|
|
39
33
|
|
|
40
34
|
display_post_install_message
|
|
@@ -42,10 +36,23 @@ module RailsFeatureFlip
|
|
|
42
36
|
|
|
43
37
|
private
|
|
44
38
|
|
|
39
|
+
def feature_load_snippet
|
|
40
|
+
<<~RUBY
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Automatically load all feature configs - you can load the features manually one by one if you want to.
|
|
44
|
+
# Be sure to comment out require_relative 'features/all' when doing so.
|
|
45
|
+
# example:
|
|
46
|
+
# require_relative 'features/foo'
|
|
47
|
+
# require_relative 'feature/bar'
|
|
48
|
+
require_relative 'features/all'
|
|
49
|
+
RUBY
|
|
50
|
+
end
|
|
51
|
+
|
|
45
52
|
def display_post_install_message
|
|
46
|
-
say
|
|
47
|
-
say "You can now generate feature setting by running the '
|
|
48
|
-
say "Run 'bin/rails generate
|
|
53
|
+
say 'RailsFeatureFlip has been successfully installed!'
|
|
54
|
+
say "You can now generate feature setting by running the 'rails_feature_flip:feature' generator."
|
|
55
|
+
say "Run 'bin/rails generate rails_feature_flip:feature -h' for usage details."
|
|
49
56
|
end
|
|
50
57
|
end
|
|
51
58
|
end
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Load all features from config/features
|
|
4
|
-
|
|
4
|
+
features_dir = File.expand_path(__dir__)
|
|
5
|
+
Dir.entries(features_dir).reject { |f| File.directory?(f) }.sort.each do |feature|
|
|
5
6
|
next if feature == 'all.rb'
|
|
6
7
|
|
|
7
8
|
require_relative feature.chomp('.rb')
|
|
8
9
|
rescue LoadError => e
|
|
9
10
|
puts e.message
|
|
10
|
-
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsFeatureFlip
|
|
4
|
+
# Provides require_feature class method for controllers.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# class HelpCenterController < ApplicationController
|
|
8
|
+
# require_feature :help_center
|
|
9
|
+
# end
|
|
10
|
+
module ControllerMethods
|
|
11
|
+
extend ActiveSupport::Concern
|
|
12
|
+
|
|
13
|
+
class_methods do
|
|
14
|
+
# Adds a before_action that returns 404 unless the feature is enabled.
|
|
15
|
+
#
|
|
16
|
+
# @param name [Symbol, String] the feature name
|
|
17
|
+
# @param options [Hash] options passed to before_action (e.g. only:, except:)
|
|
18
|
+
def require_feature(name, **options)
|
|
19
|
+
before_action(**options) do
|
|
20
|
+
head :not_found unless feature_enabled?(name)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsFeatureFlip
|
|
4
|
+
# Provides feature_enabled? helper for controllers and views.
|
|
5
|
+
#
|
|
6
|
+
# @example In a view
|
|
7
|
+
# <% if feature_enabled?(:help_center) %>
|
|
8
|
+
# <%= render 'help_center_widget' %>
|
|
9
|
+
# <% end %>
|
|
10
|
+
module Helper
|
|
11
|
+
# Checks whether a feature is enabled.
|
|
12
|
+
#
|
|
13
|
+
# @param name [Symbol, String] the feature name
|
|
14
|
+
# @return [Boolean]
|
|
15
|
+
def feature_enabled?(name)
|
|
16
|
+
feature = Rails.configuration.x.features.public_send(name)
|
|
17
|
+
feature.respond_to?(:enabled?) && feature.enabled?
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'helper'
|
|
4
|
+
require_relative 'controller_methods'
|
|
5
|
+
|
|
6
|
+
module RailsFeatureFlip
|
|
7
|
+
# Railtie that auto-includes helpers into controllers and views and provides rake tasks.
|
|
8
|
+
class Railtie < Rails::Railtie
|
|
9
|
+
rake_tasks do
|
|
10
|
+
load 'rails_feature_flip/tasks.rake'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
initializer 'rails_feature_flip.helpers' do
|
|
14
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
15
|
+
include RailsFeatureFlip::Helper
|
|
16
|
+
include RailsFeatureFlip::ControllerMethods
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
ActiveSupport.on_load(:action_view) do
|
|
20
|
+
include RailsFeatureFlip::Helper
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsFeatureFlip
|
|
4
|
+
# Provides a clean API to query all registered features.
|
|
5
|
+
#
|
|
6
|
+
# Features are stored in Rails.configuration.x.features (an OrderedOptions hash).
|
|
7
|
+
# This module wraps that hash with convenience methods.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# RailsFeatureFlip::Registry.all # => { chat: #<Features::ChatFeature>, ... }
|
|
11
|
+
# RailsFeatureFlip::Registry.names # => [:chat, :billing]
|
|
12
|
+
# RailsFeatureFlip::Registry.find(:chat) # => #<Features::ChatFeature>
|
|
13
|
+
module Registry
|
|
14
|
+
# Returns all registered features as a hash.
|
|
15
|
+
#
|
|
16
|
+
# @return [Hash{Symbol => Object}]
|
|
17
|
+
def self.all
|
|
18
|
+
features_store.to_h
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns the names of all registered features.
|
|
22
|
+
#
|
|
23
|
+
# @return [Array<Symbol>]
|
|
24
|
+
def self.names
|
|
25
|
+
features_store.keys
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Finds a feature by name.
|
|
29
|
+
#
|
|
30
|
+
# @param name [Symbol, String] the feature name
|
|
31
|
+
# @return [Object] the feature instance
|
|
32
|
+
# @raise [RailsFeatureFlip::Error] if the feature is not registered
|
|
33
|
+
def self.find(name)
|
|
34
|
+
feature = features_store[name.to_sym]
|
|
35
|
+
raise RailsFeatureFlip::Error, "Feature '#{name}' is not registered" if feature.nil?
|
|
36
|
+
|
|
37
|
+
feature
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.features_store
|
|
41
|
+
Rails.configuration.x.features
|
|
42
|
+
end
|
|
43
|
+
private_class_method :features_store
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :features do
|
|
4
|
+
desc 'List all registered features and their current values'
|
|
5
|
+
task list: :environment do
|
|
6
|
+
features = RailsFeatureFlip::Registry.all
|
|
7
|
+
|
|
8
|
+
if features.empty?
|
|
9
|
+
puts 'No features registered.'
|
|
10
|
+
next
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
rows = features.map do |name, feature|
|
|
14
|
+
enabled = feature.respond_to?(:enabled?) ? feature.enabled?.to_s : 'n/a'
|
|
15
|
+
attrs = feature_attributes(feature)
|
|
16
|
+
[name.to_s, feature.class.name, enabled, attrs]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
print_feature_table(%w[Feature Class Enabled Attributes], rows)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
desc 'List all registered features (alias for features:list)'
|
|
24
|
+
task features: 'features:list'
|
|
25
|
+
|
|
26
|
+
def feature_attributes(feature)
|
|
27
|
+
if feature.respond_to?(:attributes)
|
|
28
|
+
feature.attributes.except('enabled').map { |k, v| "#{k}: #{v.inspect}" }.join(', ')
|
|
29
|
+
else
|
|
30
|
+
ivars = feature.instance_variables - [:@enabled]
|
|
31
|
+
ivars.map { |ivar| "#{ivar.to_s.delete_prefix('@')}: #{feature.instance_variable_get(ivar).inspect}" }.join(', ')
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def print_feature_table(headers, rows)
|
|
36
|
+
widths = column_widths(headers, rows)
|
|
37
|
+
template = widths.map { |w| "%-#{w}s" }.join(' ')
|
|
38
|
+
|
|
39
|
+
puts format(template, *headers)
|
|
40
|
+
puts widths.map { |w| '-' * w }.join(' ')
|
|
41
|
+
rows.each { |row| puts format(template, *row) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def column_widths(headers, rows)
|
|
45
|
+
headers.each_with_index.map do |header, i|
|
|
46
|
+
[header.length, *rows.map { |r| r[i].to_s.length }].max
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsFeatureFlip
|
|
4
|
+
# Test helper for temporarily overriding feature values in tests.
|
|
5
|
+
#
|
|
6
|
+
# @example Include in your test class
|
|
7
|
+
# class MyTest < ActiveSupport::TestCase
|
|
8
|
+
# include RailsFeatureFlip::TestHelper
|
|
9
|
+
#
|
|
10
|
+
# test "shows widget when enabled" do
|
|
11
|
+
# with_feature(:help_center, enabled: true) do
|
|
12
|
+
# # test code that checks feature behavior
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
module TestHelper
|
|
17
|
+
# Temporarily overrides attributes on a feature and restores them after the block.
|
|
18
|
+
#
|
|
19
|
+
# @param name [Symbol, String] the feature name
|
|
20
|
+
# @param overrides [Hash] attribute values to set (e.g. enabled: true)
|
|
21
|
+
# @yield the block to execute with the overridden feature values
|
|
22
|
+
def with_feature(name, **overrides)
|
|
23
|
+
feature = Rails.configuration.x.features.public_send(name)
|
|
24
|
+
originals = save_feature_state(feature, overrides)
|
|
25
|
+
yield
|
|
26
|
+
ensure
|
|
27
|
+
restore_feature_state(feature, originals)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def save_feature_state(feature, overrides)
|
|
33
|
+
overrides.each_with_object({}) do |(attr, value), originals|
|
|
34
|
+
originals[attr] = feature.public_send(attr)
|
|
35
|
+
feature.public_send(:"#{attr}=", value)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def restore_feature_state(feature, originals)
|
|
40
|
+
originals&.each do |attr, value|
|
|
41
|
+
feature.public_send(:"#{attr}=", value)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/rails_feature_flip.rb
CHANGED
|
@@ -1,7 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'rails_feature_flip/version'
|
|
4
|
+
require_relative 'rails_feature_flip/registry'
|
|
5
|
+
require_relative 'rails_feature_flip/railtie' if defined?(Rails::Railtie)
|
|
4
6
|
|
|
5
7
|
module RailsFeatureFlip
|
|
6
8
|
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Enables a feature at runtime (in-memory, resets on restart).
|
|
11
|
+
#
|
|
12
|
+
# @param name [Symbol, String] the feature name
|
|
13
|
+
def self.enable(name)
|
|
14
|
+
Registry.find(name).enabled = true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Disables a feature at runtime (in-memory, resets on restart).
|
|
18
|
+
#
|
|
19
|
+
# @param name [Symbol, String] the feature name
|
|
20
|
+
def self.disable(name)
|
|
21
|
+
Registry.find(name).enabled = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Toggles a feature at runtime (in-memory, resets on restart).
|
|
25
|
+
#
|
|
26
|
+
# @param name [Symbol, String] the feature name
|
|
27
|
+
# @return [Boolean] the new enabled state
|
|
28
|
+
def self.toggle(name)
|
|
29
|
+
feature = Registry.find(name)
|
|
30
|
+
feature.enabled = !feature.enabled?
|
|
31
|
+
end
|
|
7
32
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-feature-flip
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Benjamin Deutscher
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: rails
|
|
@@ -16,14 +15,14 @@ dependencies:
|
|
|
16
15
|
requirements:
|
|
17
16
|
- - ">="
|
|
18
17
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '
|
|
18
|
+
version: '7.0'
|
|
20
19
|
type: :runtime
|
|
21
20
|
prerelease: false
|
|
22
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
22
|
requirements:
|
|
24
23
|
- - ">="
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '
|
|
25
|
+
version: '7.0'
|
|
27
26
|
description: A more flexible way to manage settings and features in you rails app.
|
|
28
27
|
email:
|
|
29
28
|
- ben@bdeutscher.org
|
|
@@ -31,6 +30,7 @@ executables: []
|
|
|
31
30
|
extensions: []
|
|
32
31
|
extra_rdoc_files: []
|
|
33
32
|
files:
|
|
33
|
+
- ".rubocop.yml"
|
|
34
34
|
- CHANGELOG.md
|
|
35
35
|
- CODE_OF_CONDUCT.md
|
|
36
36
|
- LICENSE.txt
|
|
@@ -43,8 +43,13 @@ files:
|
|
|
43
43
|
- lib/generators/rails_feature_flip/install/templates/all.rb
|
|
44
44
|
- lib/generators/rails_feature_flip/install/templates/initializer.rb
|
|
45
45
|
- lib/rails_feature_flip.rb
|
|
46
|
+
- lib/rails_feature_flip/controller_methods.rb
|
|
47
|
+
- lib/rails_feature_flip/helper.rb
|
|
48
|
+
- lib/rails_feature_flip/railtie.rb
|
|
49
|
+
- lib/rails_feature_flip/registry.rb
|
|
50
|
+
- lib/rails_feature_flip/tasks.rake
|
|
51
|
+
- lib/rails_feature_flip/test_helper.rb
|
|
46
52
|
- lib/rails_feature_flip/version.rb
|
|
47
|
-
- rails-feature-flip.gemspec
|
|
48
53
|
homepage: https://github.com/its-bede/rails-feature-flip
|
|
49
54
|
licenses:
|
|
50
55
|
- MIT
|
|
@@ -52,7 +57,7 @@ metadata:
|
|
|
52
57
|
homepage_uri: https://github.com/its-bede/rails-feature-flip
|
|
53
58
|
source_code_uri: https://github.com/its-bede/rails-feature-flip
|
|
54
59
|
changelog_uri: https://github.com/its-bede/rails-feature-flip/blob/main/CHANGELOG.md
|
|
55
|
-
|
|
60
|
+
rubygems_mfa_required: 'true'
|
|
56
61
|
rdoc_options: []
|
|
57
62
|
require_paths:
|
|
58
63
|
- lib
|
|
@@ -60,15 +65,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
60
65
|
requirements:
|
|
61
66
|
- - ">="
|
|
62
67
|
- !ruby/object:Gem::Version
|
|
63
|
-
version:
|
|
68
|
+
version: '3.0'
|
|
64
69
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
65
70
|
requirements:
|
|
66
71
|
- - ">="
|
|
67
72
|
- !ruby/object:Gem::Version
|
|
68
73
|
version: '0'
|
|
69
74
|
requirements: []
|
|
70
|
-
rubygems_version:
|
|
71
|
-
signing_key:
|
|
75
|
+
rubygems_version: 4.0.3
|
|
72
76
|
specification_version: 4
|
|
73
77
|
summary: Feature flipping for rails apps.
|
|
74
78
|
test_files: []
|
data/rails-feature-flip.gemspec
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "lib/rails_feature_flip/version"
|
|
4
|
-
|
|
5
|
-
Gem::Specification.new do |spec|
|
|
6
|
-
spec.name = "rails-feature-flip"
|
|
7
|
-
spec.version = RailsFeatureFlip::VERSION
|
|
8
|
-
spec.authors = ["Benjamin Deutscher"]
|
|
9
|
-
spec.email = ["ben@bdeutscher.org"]
|
|
10
|
-
|
|
11
|
-
spec.summary = "Feature flipping for rails apps."
|
|
12
|
-
spec.description = "A more flexible way to manage settings and features in you rails app."
|
|
13
|
-
spec.homepage = "https://github.com/its-bede/rails-feature-flip"
|
|
14
|
-
spec.license = "MIT"
|
|
15
|
-
spec.required_ruby_version = ">= 2.6.0"
|
|
16
|
-
|
|
17
|
-
# spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'"
|
|
18
|
-
|
|
19
|
-
spec.metadata["homepage_uri"] = spec.homepage
|
|
20
|
-
spec.metadata["source_code_uri"] = "https://github.com/its-bede/rails-feature-flip"
|
|
21
|
-
spec.metadata["changelog_uri"] = "https://github.com/its-bede/rails-feature-flip/blob/main/CHANGELOG.md"
|
|
22
|
-
|
|
23
|
-
# Specify which files should be added to the gem when it is released.
|
|
24
|
-
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
25
|
-
spec.files = Dir.chdir(__dir__) do
|
|
26
|
-
`git ls-files -z`.split("\x0").reject do |f|
|
|
27
|
-
(File.expand_path(f) == __FILE__) ||
|
|
28
|
-
f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
spec.files += Dir["lib/generators/**/*"]
|
|
33
|
-
|
|
34
|
-
spec.bindir = "exe"
|
|
35
|
-
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
36
|
-
spec.require_paths = ["lib"]
|
|
37
|
-
|
|
38
|
-
# Uncomment to register a new dependency of your gem
|
|
39
|
-
spec.add_dependency 'rails', '>= 5.0'
|
|
40
|
-
|
|
41
|
-
# For more information and examples about making a new gem, check out our
|
|
42
|
-
# guide at: https://bundler.io/guides/creating_gem.html
|
|
43
|
-
end
|