flipflop 2.2.1 → 2.3.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 +5 -13
- data/.gitignore +0 -1
- data/.travis.yml +23 -27
- data/CHANGES.md +7 -0
- data/Gemfile +14 -8
- data/README.md +27 -1
- data/Rakefile +20 -8
- data/app/controllers/flipflop/features_controller.rb +20 -10
- data/app/controllers/flipflop/strategies_controller.rb +4 -4
- data/app/views/flipflop/features/index.html.erb +64 -41
- data/app/views/flipflop/stylesheets/_flipflop.css +1 -0
- data/app/views/layouts/flipflop.html.erb +8 -1
- data/config/features.rb +7 -0
- data/config/locales/en.yml +34 -0
- data/flipflop.gemspec +0 -5
- data/lib/flipflop/configurable.rb +10 -0
- data/lib/flipflop/engine.rb +10 -15
- data/lib/flipflop/feature_definition.rb +5 -6
- data/lib/flipflop/feature_loader.rb +40 -0
- data/lib/flipflop/feature_set.rb +7 -9
- data/lib/flipflop/group_definition.rb +11 -0
- data/lib/flipflop/strategies/abstract_strategy.rb +2 -1
- data/lib/flipflop/version.rb +1 -1
- data/lib/flipflop.rb +2 -0
- data/lib/test_engine/config/features.rb +3 -0
- data/lib/test_engine/test_engine.rb +7 -0
- data/src/stylesheets/_flipflop.scss +160 -0
- data/test/integration/app_test.rb +50 -21
- data/test/integration/dashboard_test.rb +395 -52
- data/test/templates/nl.yml +30 -0
- data/test/templates/test_app_features.rb +7 -0
- data/test/templates/test_engine.rb +7 -0
- data/test/templates/test_engine_features.rb +3 -0
- data/test/test_helper.rb +83 -10
- data/test/unit/configurable_test.rb +1 -1
- data/test/unit/feature_definition_test.rb +27 -2
- data/test/unit/feature_set_test.rb +7 -19
- data/test/unit/flipflop_test.rb +16 -11
- data/test/unit/group_definition_test.rb +21 -0
- data/test/unit/strategies/abstract_strategy_test.rb +14 -25
- data/test/unit/strategies/active_record_strategy_test.rb +4 -0
- data/test/unit/strategies/cookie_strategy_test.rb +4 -0
- data/test/unit/strategies/default_strategy_test.rb +10 -4
- data/test/unit/strategies/lambda_strategy_test.rb +11 -27
- data/test/unit/strategies/query_string_strategy_test.rb +4 -0
- data/test/unit/strategies/redis_strategy_test.rb +4 -0
- data/test/unit/strategies/session_strategy_test.rb +4 -0
- data/test/unit/strategies/test_strategy_test.rb +4 -0
- metadata +22 -9
- data/app/assets/stylesheets/flipflop.css +0 -1
@@ -0,0 +1,34 @@
|
|
1
|
+
en:
|
2
|
+
flipflop:
|
3
|
+
title: "%{application} Features"
|
4
|
+
feature: "Feature"
|
5
|
+
description: "Description"
|
6
|
+
|
7
|
+
feature_states:
|
8
|
+
enabled: "on"
|
9
|
+
disabled: "off"
|
10
|
+
|
11
|
+
clear: "clear"
|
12
|
+
|
13
|
+
# You can optionally translate the names of the chosen features, feature
|
14
|
+
# groups and strategies:
|
15
|
+
|
16
|
+
groups:
|
17
|
+
default: "Other features"
|
18
|
+
|
19
|
+
# groups:
|
20
|
+
# design_improvements: "Design improvements"
|
21
|
+
|
22
|
+
# features:
|
23
|
+
# world_domination: "World domination"
|
24
|
+
# world_domination_description: "Take over the world!"
|
25
|
+
|
26
|
+
# strategies:
|
27
|
+
# cookie: "Cookie"
|
28
|
+
# active_record: "Active record"
|
29
|
+
# default: "Default"
|
30
|
+
# lambda: "Lambda"
|
31
|
+
# query_string: "Query string"
|
32
|
+
# redis: "Redis"
|
33
|
+
# session: "Session"
|
34
|
+
# test: "Test"
|
data/flipflop.gemspec
CHANGED
@@ -14,12 +14,7 @@ Gem::Specification.new do |s|
|
|
14
14
|
s.license = "MIT"
|
15
15
|
|
16
16
|
s.add_dependency("activesupport", ">= 4.0")
|
17
|
-
if ENV["CONTINUOUS_INTEGRATION"]
|
18
|
-
s.files = `git ls-files`.split("\n").map { |f| f.sub(".scss", ".css") }
|
19
|
-
else
|
20
|
-
s.add_dependency("bootstrap", "= 4.0.0.alpha3")
|
21
17
|
s.files = `git ls-files`.split("\n")
|
22
|
-
end
|
23
18
|
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
24
19
|
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
25
20
|
s.require_paths = ["lib"]
|
@@ -1,6 +1,16 @@
|
|
1
1
|
module Flipflop
|
2
2
|
module Configurable
|
3
|
+
attr_accessor :current_group
|
4
|
+
|
5
|
+
def group(group)
|
6
|
+
self.current_group = GroupDefinition.new(group)
|
7
|
+
yield
|
8
|
+
ensure
|
9
|
+
self.current_group = nil
|
10
|
+
end
|
11
|
+
|
3
12
|
def feature(feature, **options)
|
13
|
+
options = options.merge(group: current_group)
|
4
14
|
feature = FeatureDefinition.new(feature, **options)
|
5
15
|
FeatureSet.current.add(feature)
|
6
16
|
end
|
data/lib/flipflop/engine.rb
CHANGED
@@ -4,23 +4,24 @@ module Flipflop
|
|
4
4
|
|
5
5
|
isolate_namespace Flipflop
|
6
6
|
|
7
|
+
# The following middleware needs to be inserted for this engine, because it
|
8
|
+
# may not be available in Rails API only apps.
|
9
|
+
middleware.use Rack::MethodOverride
|
10
|
+
middleware.use ActionDispatch::Cookies
|
11
|
+
|
7
12
|
config.app_middleware.insert_after ActionDispatch::Callbacks,
|
8
13
|
FeatureCache::Middleware
|
9
14
|
|
10
15
|
config.flipflop = ActiveSupport::OrderedOptions.new
|
11
16
|
|
12
|
-
initializer "flipflop.assets" do |app|
|
13
|
-
config.assets.precompile += ["flipflop.css"]
|
14
|
-
end
|
15
|
-
|
16
17
|
initializer "flipflop.features_path" do |app|
|
17
|
-
|
18
|
+
FeatureLoader.current.append(app)
|
18
19
|
end
|
19
20
|
|
20
|
-
initializer "flipflop.
|
21
|
-
app.reloaders.push(
|
21
|
+
initializer "flipflop.features_loader" do |app|
|
22
|
+
app.reloaders.push(FeatureLoader.current)
|
22
23
|
to_prepare do
|
23
|
-
|
24
|
+
FeatureLoader.current.execute
|
24
25
|
end
|
25
26
|
end
|
26
27
|
|
@@ -37,6 +38,7 @@ module Flipflop
|
|
37
38
|
initializer "flipflop.request_interceptor" do |app|
|
38
39
|
interceptor = Strategies::AbstractStrategy::RequestInterceptor
|
39
40
|
ActionController::Base.send(:include, interceptor)
|
41
|
+
ActionController::API.send(:include, interceptor) if defined?(ActionController::API)
|
40
42
|
end
|
41
43
|
|
42
44
|
def run_tasks_blocks(app)
|
@@ -47,13 +49,6 @@ module Flipflop
|
|
47
49
|
|
48
50
|
private
|
49
51
|
|
50
|
-
def feature_reloader(app)
|
51
|
-
features = app.paths["config/features.rb"].existent
|
52
|
-
ActiveSupport::FileUpdateChecker.new(features) do
|
53
|
-
features.each { |path| load(path) }
|
54
|
-
end
|
55
|
-
end
|
56
|
-
|
57
52
|
def to_prepare
|
58
53
|
klass = defined?(ActiveSupport::Reloader) ? ActiveSupport::Reloader : ActionDispatch::Reloader
|
59
54
|
klass.to_prepare(&Proc.new)
|
@@ -1,15 +1,14 @@
|
|
1
1
|
module Flipflop
|
2
2
|
class FeatureDefinition
|
3
|
-
attr_reader :key, :default, :
|
3
|
+
attr_reader :key, :name, :title, :description, :default, :group
|
4
4
|
|
5
5
|
def initialize(key, **options)
|
6
6
|
@key = key
|
7
|
+
@name = @key.to_s.freeze
|
8
|
+
@title = @name.humanize.freeze
|
9
|
+
@description = options.delete(:description).freeze
|
7
10
|
@default = !!options.delete(:default) || false
|
8
|
-
@
|
9
|
-
end
|
10
|
-
|
11
|
-
def name
|
12
|
-
key.to_s
|
11
|
+
@group = options.delete(:group).freeze
|
13
12
|
end
|
14
13
|
end
|
15
14
|
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Flipflop
|
2
|
+
class FeatureLoader
|
3
|
+
@@lock = Monitor.new
|
4
|
+
|
5
|
+
class << self
|
6
|
+
def current
|
7
|
+
@current or @@lock.synchronize { @current ||= new }
|
8
|
+
end
|
9
|
+
|
10
|
+
private :new
|
11
|
+
end
|
12
|
+
|
13
|
+
extend Forwardable
|
14
|
+
delegate [:execute, :updated?] => :checker
|
15
|
+
|
16
|
+
def initialize
|
17
|
+
@paths = []
|
18
|
+
end
|
19
|
+
|
20
|
+
def append(engine)
|
21
|
+
@paths.concat(engine.paths.add("config/features.rb".freeze).existent)
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def checker
|
27
|
+
@checker or @@lock.synchronize do
|
28
|
+
@checker ||= ActiveSupport::FileUpdateChecker.new(@paths) { reload! }
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def reload!
|
33
|
+
@@lock.synchronize do
|
34
|
+
Flipflop::FeatureSet.current.replace do
|
35
|
+
@paths.each { |path| Kernel.load(path) }
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
data/lib/flipflop/feature_set.rb
CHANGED
@@ -28,21 +28,19 @@ module Flipflop
|
|
28
28
|
end
|
29
29
|
|
30
30
|
def configure
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
extend Configurable
|
35
|
-
instance_exec(&Proc.new)
|
36
|
-
end
|
37
|
-
@features.freeze
|
38
|
-
@strategies.freeze
|
31
|
+
Module.new do
|
32
|
+
extend Configurable
|
33
|
+
instance_exec(&Proc.new)
|
39
34
|
end
|
40
35
|
self
|
41
36
|
end
|
42
37
|
|
43
|
-
def
|
38
|
+
def replace
|
44
39
|
@@lock.synchronize do
|
45
40
|
initialize
|
41
|
+
yield if block_given?
|
42
|
+
@features.freeze
|
43
|
+
@strategies.freeze
|
46
44
|
end
|
47
45
|
self
|
48
46
|
end
|
@@ -35,7 +35,7 @@ module Flipflop
|
|
35
35
|
end
|
36
36
|
end
|
37
37
|
|
38
|
-
attr_reader :key, :name, :description
|
38
|
+
attr_reader :key, :name, :title, :description
|
39
39
|
|
40
40
|
def initialize(**options)
|
41
41
|
# Generate key before setting instance that should be excluded from
|
@@ -43,6 +43,7 @@ module Flipflop
|
|
43
43
|
@key = OptionsHasher.new(self).generate
|
44
44
|
|
45
45
|
@name = (options.delete(:name) || self.class.default_name).freeze
|
46
|
+
@title = @name.humanize.freeze
|
46
47
|
@description = (options.delete(:description) || self.class.default_description).freeze
|
47
48
|
@hidden = !!options.delete(:hidden) || false
|
48
49
|
|
data/lib/flipflop/version.rb
CHANGED
data/lib/flipflop.rb
CHANGED
@@ -8,7 +8,9 @@ require "flipflop/configurable"
|
|
8
8
|
require "flipflop/facade"
|
9
9
|
require "flipflop/feature_cache"
|
10
10
|
require "flipflop/feature_definition"
|
11
|
+
require "flipflop/feature_loader"
|
11
12
|
require "flipflop/feature_set"
|
13
|
+
require "flipflop/group_definition"
|
12
14
|
|
13
15
|
require "flipflop/strategies/abstract_strategy"
|
14
16
|
require "flipflop/strategies/options_hasher"
|
@@ -0,0 +1,160 @@
|
|
1
|
+
$enable-transitions: true;
|
2
|
+
$grid-gutter-width-base: 0;
|
3
|
+
|
4
|
+
@import "bootstrap/variables";
|
5
|
+
@import "bootstrap/mixins";
|
6
|
+
@import "bootstrap/normalize";
|
7
|
+
@import "bootstrap/print";
|
8
|
+
|
9
|
+
@import "bootstrap/reboot";
|
10
|
+
@import "bootstrap/type";
|
11
|
+
@import "bootstrap/tables";
|
12
|
+
@import "bootstrap/buttons";
|
13
|
+
|
14
|
+
@import "bootstrap/button-group";
|
15
|
+
@import "bootstrap/badge";
|
16
|
+
|
17
|
+
@import "bootstrap/utilities/spacing";
|
18
|
+
|
19
|
+
section.flipflop {
|
20
|
+
@include make-container(); /* @extend .container-fluid; */
|
21
|
+
margin: 5rem 0 0;
|
22
|
+
|
23
|
+
h1 {
|
24
|
+
padding: 0 1.4rem;
|
25
|
+
}
|
26
|
+
|
27
|
+
table {
|
28
|
+
@extend .table;
|
29
|
+
|
30
|
+
td:first-child {
|
31
|
+
padding-left: 1.5rem;
|
32
|
+
}
|
33
|
+
|
34
|
+
td:last-child {
|
35
|
+
padding-right: 1.5rem;
|
36
|
+
}
|
37
|
+
|
38
|
+
thead {
|
39
|
+
@extend .thead-inverse;
|
40
|
+
|
41
|
+
tr {
|
42
|
+
th {
|
43
|
+
position: relative;
|
44
|
+
cursor: default;
|
45
|
+
|
46
|
+
&[data-tooltip]:before, &[data-tooltip]:after {
|
47
|
+
@include transition(all 0.2s ease-out);
|
48
|
+
transform: translateY(0.2rem) translateZ(0);
|
49
|
+
opacity: 0;
|
50
|
+
|
51
|
+
display: block;
|
52
|
+
position: absolute;
|
53
|
+
}
|
54
|
+
|
55
|
+
&[data-tooltip]:before {
|
56
|
+
content: attr(data-tooltip);
|
57
|
+
width: 98%;
|
58
|
+
left: 0;
|
59
|
+
bottom: 3.75rem;
|
60
|
+
margin: 0;
|
61
|
+
padding: 0.5rem 0.75rem;
|
62
|
+
background: $gray;
|
63
|
+
border-radius: 0.2rem;
|
64
|
+
font-size: 0.875rem;
|
65
|
+
font-weight: normal;
|
66
|
+
pointer-events: none;
|
67
|
+
}
|
68
|
+
|
69
|
+
&[data-tooltip]:after {
|
70
|
+
content: " ";
|
71
|
+
width: 0;
|
72
|
+
height: 0;
|
73
|
+
left: 1rem;
|
74
|
+
bottom: 3.25rem;
|
75
|
+
border-left: solid transparent 0.5rem;
|
76
|
+
border-right: solid transparent 0.5rem;
|
77
|
+
border-top: solid $gray 0.5rem;
|
78
|
+
}
|
79
|
+
|
80
|
+
&:hover {
|
81
|
+
&[data-tooltip]:before, &[data-tooltip]:after {
|
82
|
+
transform: translateY(0) translateZ(0);
|
83
|
+
opacity: 1;
|
84
|
+
}
|
85
|
+
}
|
86
|
+
}
|
87
|
+
}
|
88
|
+
}
|
89
|
+
|
90
|
+
tbody {
|
91
|
+
tr {
|
92
|
+
td.status {
|
93
|
+
width: 2.2rem;
|
94
|
+
font-size: 1.1rem;
|
95
|
+
|
96
|
+
span.enabled, span.disabled {
|
97
|
+
width: 2rem;
|
98
|
+
@extend .badge;
|
99
|
+
@extend .badge-pill;
|
100
|
+
span {
|
101
|
+
width: auto;
|
102
|
+
text-align: center;
|
103
|
+
margin: 0 -2rem;
|
104
|
+
}
|
105
|
+
}
|
106
|
+
|
107
|
+
span.enabled { @extend .badge-success; }
|
108
|
+
span.disabled { @extend .badge-default; }
|
109
|
+
}
|
110
|
+
|
111
|
+
td.name {
|
112
|
+
min-width: 12rem;
|
113
|
+
padding-top: 0.9rem;
|
114
|
+
font-weight: bold;
|
115
|
+
}
|
116
|
+
|
117
|
+
td.description {
|
118
|
+
min-width: 12rem;
|
119
|
+
padding-top: 0.9rem;
|
120
|
+
}
|
121
|
+
|
122
|
+
td.toggle {
|
123
|
+
min-width: 10rem;
|
124
|
+
|
125
|
+
div.toolbar {
|
126
|
+
@extend .btn-toolbar;
|
127
|
+
margin-left: 0;
|
128
|
+
|
129
|
+
div.group {
|
130
|
+
@extend .mr-2;
|
131
|
+
@extend .btn-group;
|
132
|
+
@extend .btn-group-sm;
|
133
|
+
|
134
|
+
button {
|
135
|
+
@extend .btn;
|
136
|
+
@extend .btn-sm;
|
137
|
+
cursor: pointer;
|
138
|
+
&.active { @extend .btn-primary; }
|
139
|
+
&:not(.active) { @extend .btn-secondary; }
|
140
|
+
}
|
141
|
+
}
|
142
|
+
}
|
143
|
+
}
|
144
|
+
|
145
|
+
&.group {
|
146
|
+
background-color: $table-bg-accent;
|
147
|
+
|
148
|
+
td {
|
149
|
+
h2 {
|
150
|
+
margin: 0;
|
151
|
+
line-height: inherit;
|
152
|
+
font-size: 1.4rem;
|
153
|
+
font-weight: bold;
|
154
|
+
}
|
155
|
+
}
|
156
|
+
}
|
157
|
+
}
|
158
|
+
}
|
159
|
+
}
|
160
|
+
}
|
@@ -1,36 +1,65 @@
|
|
1
1
|
require File.expand_path("../../test_helper", __FILE__)
|
2
2
|
|
3
3
|
describe Flipflop do
|
4
|
-
|
5
|
-
|
6
|
-
|
4
|
+
describe "without engine" do
|
5
|
+
before do
|
6
|
+
@app = TestApp.new
|
7
|
+
end
|
7
8
|
|
8
|
-
|
9
|
-
|
10
|
-
|
9
|
+
after do
|
10
|
+
@app.unload!
|
11
|
+
end
|
11
12
|
|
12
|
-
|
13
|
-
|
14
|
-
|
13
|
+
subject do
|
14
|
+
@app
|
15
|
+
end
|
15
16
|
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
17
|
+
describe "middleware" do
|
18
|
+
it "should include cache middleware" do
|
19
|
+
middlewares = Rails.application.middleware.map(&:klass)
|
20
|
+
assert_includes middlewares, Flipflop::FeatureCache::Middleware
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
describe "module" do
|
25
|
+
before do
|
26
|
+
Flipflop::FeatureSet.current.instance_variable_set(:@features, {})
|
27
|
+
Module.new do
|
28
|
+
extend Flipflop::Configurable
|
29
|
+
feature :world_domination
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
it "should allow querying for app features" do
|
34
|
+
assert_equal false, Flipflop.world_domination?
|
35
|
+
end
|
20
36
|
end
|
21
37
|
end
|
22
38
|
|
23
|
-
describe "
|
39
|
+
describe "with engine" do
|
24
40
|
before do
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
41
|
+
@app = TestApp.new([
|
42
|
+
TestFeaturesGenerator,
|
43
|
+
TestEngineGenerator,
|
44
|
+
])
|
45
|
+
end
|
46
|
+
|
47
|
+
after do
|
48
|
+
@app.unload!
|
30
49
|
end
|
31
50
|
|
32
|
-
|
33
|
-
|
51
|
+
subject do
|
52
|
+
@app
|
53
|
+
end
|
54
|
+
|
55
|
+
describe "module" do
|
56
|
+
it "should allow querying for app features" do
|
57
|
+
assert_equal false, Flipflop.application_feature?
|
58
|
+
end
|
59
|
+
|
60
|
+
it "should allow querying for engine features" do
|
61
|
+
assert_equal false, Flipflop.engine_feature?
|
62
|
+
end
|
34
63
|
end
|
35
64
|
end
|
36
65
|
end
|