detour 0.0.1 → 0.0.2

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.
Files changed (147) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +2 -1
  3. data/.travis.yml +6 -2
  4. data/Gemfile +5 -0
  5. data/README.md +41 -132
  6. data/Rake.md +108 -0
  7. data/Rakefile +21 -1
  8. data/app/assets/fonts/glyphicons-halflings-regular.eot +0 -0
  9. data/app/assets/fonts/glyphicons-halflings-regular.svg +229 -0
  10. data/app/assets/fonts/glyphicons-halflings-regular.ttf +0 -0
  11. data/app/assets/fonts/glyphicons-halflings-regular.woff +0 -0
  12. data/app/assets/images/detour/.gitkeep +0 -0
  13. data/app/assets/javascripts/detour/application.js +16 -0
  14. data/app/assets/javascripts/detour/bootstrap.js +2006 -0
  15. data/app/assets/javascripts/detour/delete_feature.js +13 -0
  16. data/app/assets/javascripts/detour/delete_flag.js +13 -0
  17. data/app/assets/javascripts/detour/modals.js +3 -0
  18. data/app/assets/javascripts/detour/tooltips.js +3 -0
  19. data/app/assets/stylesheets/detour/application.css +14 -0
  20. data/app/assets/stylesheets/detour/bootstrap.css +7112 -0
  21. data/app/assets/stylesheets/detour/main.css +49 -0
  22. data/app/controllers/detour/application_controller.rb +12 -0
  23. data/app/controllers/detour/features_controller.rb +19 -0
  24. data/app/controllers/detour/flaggable_flags_controller.rb +90 -0
  25. data/app/controllers/detour/flags_controller.rb +18 -0
  26. data/app/helpers/detour/application_helper.rb +32 -0
  27. data/app/helpers/detour/flaggable_flags_helper.rb +5 -0
  28. data/app/helpers/detour/flags_helper.rb +7 -0
  29. data/app/models/detour/concerns/countable_flag.rb +19 -0
  30. data/app/models/detour/concerns/flag_actions.rb +141 -0
  31. data/app/models/detour/concerns/matchers.rb +73 -0
  32. data/app/models/detour/feature.rb +81 -0
  33. data/{lib → app/models}/detour/flag.rb +8 -2
  34. data/app/models/detour/flag_in_flag.rb +12 -0
  35. data/app/models/detour/group_flag.rb +13 -0
  36. data/{lib → app/models}/detour/opt_out_flag.rb +4 -2
  37. data/app/models/detour/percentage_flag.rb +9 -0
  38. data/app/views/detour/application/index.html.erb +0 -0
  39. data/app/views/detour/features/_errors.html.erb +11 -0
  40. data/app/views/detour/features/_success.html.erb +1 -0
  41. data/app/views/detour/features/error.js.erb +5 -0
  42. data/app/views/detour/features/success.js.erb +1 -0
  43. data/app/views/detour/flaggable_flags/_errors.html.erb +11 -0
  44. data/app/views/detour/flaggable_flags/_flaggable_flag.html.erb +11 -0
  45. data/app/views/detour/flaggable_flags/error.js.erb +5 -0
  46. data/app/views/detour/flaggable_flags/index.html.erb +34 -0
  47. data/app/views/detour/flaggable_flags/success.js.erb +1 -0
  48. data/app/views/detour/flags/_feature_form.html.erb +38 -0
  49. data/app/views/detour/flags/index.html.erb +76 -0
  50. data/app/views/detour/shared/_nav.html.erb +28 -0
  51. data/app/views/detour/shared/_spacer_cells.html.erb +3 -0
  52. data/app/views/layouts/detour/application.html.erb +29 -0
  53. data/config/routes.rb +16 -0
  54. data/detour.gemspec +15 -14
  55. data/lib/detour/acts_as_flaggable.rb +42 -3
  56. data/lib/detour/configuration.rb +35 -0
  57. data/lib/detour/engine.rb +5 -0
  58. data/lib/detour/flag_form.rb +87 -0
  59. data/lib/detour/version.rb +1 -1
  60. data/lib/detour.rb +10 -14
  61. data/lib/generators/templates/migration.rb +2 -0
  62. data/lib/tasks/detour.rake +16 -16
  63. data/script/rails +8 -0
  64. data/spec/controllers/detour/application_controller_spec.rb +15 -0
  65. data/spec/controllers/detour/features_controller_spec.rb +51 -0
  66. data/spec/controllers/detour/flaggable_flags_controller_spec.rb +100 -0
  67. data/spec/controllers/detour/flags_controller_spec.rb +77 -0
  68. data/spec/dummy/README.rdoc +261 -0
  69. data/spec/dummy/Rakefile +7 -0
  70. data/spec/dummy/app/assets/javascripts/application.js +15 -0
  71. data/spec/dummy/app/assets/stylesheets/application.css +13 -0
  72. data/spec/dummy/app/controllers/application_controller.rb +7 -0
  73. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  74. data/spec/dummy/app/mailers/.gitkeep +0 -0
  75. data/spec/dummy/app/models/.gitkeep +0 -0
  76. data/spec/dummy/app/models/user.rb +5 -0
  77. data/spec/dummy/app/models/widget.rb +5 -0
  78. data/spec/dummy/app/views/application/index.html.erb +22 -0
  79. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  80. data/spec/dummy/config/application.rb +65 -0
  81. data/spec/dummy/config/boot.rb +10 -0
  82. data/spec/dummy/config/database.yml +19 -0
  83. data/spec/dummy/config/environment.rb +5 -0
  84. data/spec/dummy/config/environments/development.rb +37 -0
  85. data/spec/dummy/config/environments/production.rb +67 -0
  86. data/spec/dummy/config/environments/test.rb +37 -0
  87. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  88. data/spec/dummy/config/initializers/detour.rb +36 -0
  89. data/spec/dummy/config/initializers/inflections.rb +15 -0
  90. data/spec/dummy/config/initializers/mime_types.rb +5 -0
  91. data/spec/dummy/config/initializers/secret_token.rb +7 -0
  92. data/spec/dummy/config/initializers/session_store.rb +8 -0
  93. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  94. data/spec/dummy/config/locales/en.yml +5 -0
  95. data/spec/dummy/config/routes.rb +4 -0
  96. data/spec/dummy/config.ru +4 -0
  97. data/spec/dummy/db/migrate/20131218015844_create_users.rb +10 -0
  98. data/spec/dummy/db/migrate/20131218023124_create_widgets.rb +9 -0
  99. data/spec/dummy/db/migrate/20131218055352_add_user_id_to_widgets.rb +6 -0
  100. data/spec/dummy/db/migrate/20131221052201_setup_detour.rb +32 -0
  101. data/spec/dummy/db/schema.rb +59 -0
  102. data/spec/dummy/db/seeds.rb +10 -0
  103. data/spec/dummy/lib/assets/.gitkeep +0 -0
  104. data/spec/dummy/log/.gitkeep +0 -0
  105. data/spec/dummy/public/404.html +26 -0
  106. data/spec/dummy/public/422.html +26 -0
  107. data/spec/dummy/public/500.html +25 -0
  108. data/spec/dummy/public/favicon.ico +0 -0
  109. data/spec/dummy/script/rails +6 -0
  110. data/spec/factories/feature.rb +5 -0
  111. data/spec/factories/flag_in_flag.rb +6 -0
  112. data/spec/factories/group_flag.rb +7 -0
  113. data/spec/factories/opt_out_flag.rb +6 -0
  114. data/spec/factories/percentage_flag.rb +7 -0
  115. data/spec/factories/user.rb +6 -0
  116. data/spec/factories/widget.rb +5 -0
  117. data/spec/features/features_spec.rb +70 -0
  118. data/spec/features/flag_in_flags_spec.rb +118 -0
  119. data/spec/features/group_flags_spec.rb +49 -0
  120. data/spec/features/home_page_spec.rb +11 -0
  121. data/spec/features/opt_out_flags_spec.rb +105 -0
  122. data/spec/features/percentage_flags_spec.rb +63 -0
  123. data/spec/integration/group_rollout_spec.rb +1 -1
  124. data/spec/lib/detour/acts_as_flaggable_spec.rb +45 -0
  125. data/spec/lib/detour/configuration_spec.rb +23 -0
  126. data/spec/lib/detour/flag_form_spec.rb +84 -0
  127. data/spec/lib/{active_record/rollout → detour}/flaggable_spec.rb +19 -19
  128. data/spec/lib/tasks/{detour_rake_spec.rb → detour_spec.rb} +54 -54
  129. data/spec/{lib/active_record/rollout → models/detour}/feature_spec.rb +93 -67
  130. data/spec/models/detour/flag_in_flag_spec.rb +38 -0
  131. data/spec/{lib/active_record/rollout → models/detour}/flag_spec.rb +1 -1
  132. data/spec/models/detour/opt_out_flag_spec.rb +38 -0
  133. data/spec/{lib/active_record/rollout → models/detour}/percentage_flag_spec.rb +1 -1
  134. data/spec/spec_helper.rb +41 -21
  135. data/spec/support/shared_contexts/rake.rb +4 -14
  136. metadata +278 -95
  137. data/lib/detour/feature.rb +0 -312
  138. data/lib/detour/flaggable_flag.rb +0 -10
  139. data/lib/detour/group_flag.rb +0 -8
  140. data/lib/detour/percentage_flag.rb +0 -11
  141. data/spec/lib/active_record/rollout/acts_as_flaggable_spec.rb +0 -31
  142. data/spec/lib/active_record/rollout/flaggable_flag_spec.rb +0 -9
  143. data/spec/lib/active_record/rollout/opt_out_flag_spec.rb +0 -9
  144. data/spec/support/schema.rb +0 -13
  145. /data/lib/generators/{active_record_rollout_generator.rb → detour_generator.rb} +0 -0
  146. /data/lib/generators/templates/{active_record_rollout.rb → detour.rb} +0 -0
  147. /data/spec/{lib/active_record/rollout → models/detour}/group_flag_spec.rb +0 -0
@@ -0,0 +1,49 @@
1
+ @font-face {
2
+ font-family: 'Glyphicons Halflings';
3
+ src: url('/assets/glyphicons-halflings-regular.eot');
4
+ src: url('/assets/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('/assets/glyphicons-halflings-regular.woff') format('woff'), url('/assets/glyphicons-halflings-regular.ttf') format('truetype'), url('/assets/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg');
5
+ }
6
+
7
+ body {
8
+ padding-bottom: 40px;
9
+ }
10
+
11
+ .panel-danger {
12
+ margin-top: 20px;
13
+ }
14
+
15
+ .panel-danger ul {
16
+ margin-bottom: 0;
17
+ }
18
+
19
+ .modal-body .panel-danger {
20
+ margin-top: 0;
21
+ }
22
+
23
+ .table-responsive {
24
+ padding-top: 60px;
25
+ }
26
+
27
+ table.table tbody tr td {
28
+ vertical-align: middle;
29
+ }
30
+
31
+ .group-header {
32
+ display: block;
33
+ width: 10px;
34
+ white-space: nowrap;
35
+
36
+ -webkit-transform: rotate(-30deg);
37
+ -moz-transform: rotate(-30deg);
38
+ -ms-transform: rotate(-30deg);
39
+ -o-transform: rotate(-30deg);
40
+ transform: rotate(-30deg);
41
+ }
42
+
43
+ .percentage-header {
44
+ width: 120px;
45
+ }
46
+
47
+ .counter-header {
48
+ width: 80px;
49
+ }
@@ -0,0 +1,12 @@
1
+ class Detour::ApplicationController < ActionController::Base
2
+ def index
3
+ end
4
+
5
+ private
6
+
7
+ def ensure_flaggable_type_exists
8
+ unless Detour.config.flaggable_types.map(&:tableize).include? params[:flaggable_type]
9
+ raise ActionController::RoutingError.new("Not Found")
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,19 @@
1
+ class Detour::FeaturesController < Detour::ApplicationController
2
+ def create
3
+ @feature = Detour::Feature.new(params[:feature])
4
+
5
+ if @feature.save
6
+ flash[:notice] = "Your feature has been successfully created."
7
+ render :success
8
+ else
9
+ render :error
10
+ end
11
+ end
12
+
13
+ def destroy
14
+ @feature = Detour::Feature.find(params[:id])
15
+ @feature.destroy
16
+ flash[:notice] = "Feature #{@feature.name} has been deleted."
17
+ redirect_to flags_path(params[:flaggable_type])
18
+ end
19
+ end
@@ -0,0 +1,90 @@
1
+ require "indefinite_article"
2
+
3
+ class Detour::FlaggableFlagsController < Detour::ApplicationController
4
+ before_filter :ensure_flaggable_type_exists
5
+
6
+ def index
7
+ feature = Detour::Feature.find_by_name!(feature_name)
8
+ @flags = feature.send("#{flag_type}_flags").where(flaggable_type: flaggable_class.to_s)
9
+ end
10
+
11
+ def create
12
+ @feature = Detour::Feature.find_by_name! feature_name
13
+ ids = params[:ids].split(",")
14
+ @errors = []
15
+
16
+ Detour::Feature.transaction do
17
+ begin
18
+ ids.each do |id|
19
+ flaggable = flaggable_class.flaggable_find! id
20
+ flag = @feature.send("#{flaggable_type}_#{flag_type.pluralize}").new flaggable: flaggable
21
+
22
+ unless flag.save
23
+ @errors.concat flag.errors.full_messages
24
+ end
25
+ end
26
+ rescue ActiveRecord::RecordNotFound => e
27
+ @errors << e.message
28
+ end
29
+
30
+ if @errors.any?
31
+ raise ActiveRecord::Rollback
32
+ end
33
+ end
34
+
35
+ if @errors.empty?
36
+ flash[:notice] = success_message
37
+ render :success
38
+ else
39
+ render :error
40
+ end
41
+ end
42
+
43
+ def destroy
44
+ feature = Detour::Feature.find_by_name!(feature_name)
45
+ @flag = feature.send("#{flag_type}_flags").find(params[:id])
46
+ @flag.destroy
47
+ flash[:notice] = "#{feature_name} #{flag_noun} for #{flaggable_class} #{@flag.flaggable.send flaggable_class.detour_flaggable_find_by} has been deleted."
48
+ redirect_to send("#{flag_type}_flags_path", feature.name, flaggable_type)
49
+ end
50
+
51
+ private
52
+
53
+ def feature_name
54
+ params[:feature_name]
55
+ end
56
+ helper_method :feature_name
57
+
58
+ def flaggable_type
59
+ params[:flaggable_type]
60
+ end
61
+ helper_method :flaggable_type
62
+
63
+ def flaggable_class
64
+ flaggable_type.classify.constantize
65
+ end
66
+ helper_method :flaggable_class
67
+
68
+ def flag_type
69
+ request.path.split("/")[2].underscore.singularize
70
+ end
71
+ helper_method :flag_type
72
+
73
+ def flag_verb
74
+ flag_type == "flag_in" ? "flagged in to" : "opted out of"
75
+ end
76
+ helper_method :flag_verb
77
+
78
+ def flag_noun
79
+ flag_type.dasherize
80
+ end
81
+ helper_method :flag_noun
82
+
83
+ def success_message
84
+ plural = params[:ids].split(",").length > 1
85
+ klass = plural ? flaggable_class.to_s.pluralize : flaggable_class
86
+ has = plural ? "have" : "has"
87
+
88
+ flash[:notice] = "#{klass} #{params[:ids].split(",").join(", ")} #{has} been #{flag_verb} #{@feature.name}"
89
+ end
90
+ end
@@ -0,0 +1,18 @@
1
+ class Detour::FlagsController < Detour::ApplicationController
2
+ before_filter :ensure_flaggable_type_exists
3
+
4
+ def index
5
+ @flag_form = Detour::FlagForm.new(params[:flaggable_type])
6
+ end
7
+
8
+ def update
9
+ @flag_form = Detour::FlagForm.new(params[:flaggable_type])
10
+
11
+ if @flag_form.update_attributes(params)
12
+ flash[:notice] = "Your flags have been successfully updated."
13
+ redirect_to flags_path
14
+ else
15
+ render :index
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,32 @@
1
+ module Detour::ApplicationHelper
2
+ def table(&block)
3
+ content_tag :div, class: "table-responsive" do
4
+ content_tag :table, class: "table table-striped" do
5
+ yield
6
+ end
7
+ end
8
+ end
9
+
10
+ def modal(options, &block)
11
+ content_tag :div, id: options[:id], class: "modal #{options[:fade].present?}", tabindex: "-1", role: "dialog", aria_labbeledby: "#{options[:id]}-modal-label", aria_hidden: "true" do
12
+ content_tag :div, class: "modal-dialog" do
13
+ content_tag :div, class: "modal-content" do
14
+ content_tag(:div, class: "modal-header") do
15
+ content_tag :button, "&times;", class: "close", data_dismiss: "modal", aria_hidden: "true"
16
+ content_tag :h4, options[:title], id: "#{options[:id]}-modal-label"
17
+ end +
18
+
19
+ content_tag(:div, class: "modal-body") do
20
+ yield
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ def modal_footer(&block)
28
+ content_tag :div, class: "modal-footer" do
29
+ yield
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,5 @@
1
+ module Detour::FlaggableFlagsHelper
2
+ def flag_title
3
+ flag_noun.capitalize
4
+ end
5
+ end
@@ -0,0 +1,7 @@
1
+ module Detour::FlagsHelper
2
+ def spacer_count
3
+ names_count = @flag_form.group_names.length
4
+ difference = 10 - names_count
5
+ count = difference < 0 ? 0 : difference
6
+ end
7
+ end
@@ -0,0 +1,19 @@
1
+ module Detour::Concerns
2
+ module CountableFlag
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ after_save :step_count
7
+ after_destroy :step_count
8
+
9
+ private
10
+
11
+ def step_count
12
+ count = feature.send("#{flag_type}_count_for", flaggable_type.tableize)
13
+ count = destroyed? ? count - 1 : count + 1
14
+ feature.send("#{flag_type}_counts")[flaggable_type.tableize] = count
15
+ feature.save!
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,141 @@
1
+ module Detour::Concerns
2
+ module FlagActions
3
+ extend ActiveSupport::Concern
4
+
5
+ module ClassMethods
6
+ # Add a record to the given feature. If the feature is not found, an
7
+ # ActiveRecord::RecordNotFound will be raised.
8
+ #
9
+ # @example
10
+ # Detour::Feature.add_record_to_feature user, :new_ui
11
+ #
12
+ # @param [ActiveRecord::Base] record A record to add the feature to.
13
+ # @param [String,Symbol] feature_name The feature to be added to the record.
14
+ #
15
+ # @return [Detour::Flag] The
16
+ # {Detour::Flag Flag} created.
17
+ def add_record_to_feature(record, feature_name)
18
+ feature = find_by_name!(feature_name)
19
+ feature.flag_in_flags.where(flaggable_type: record.class.to_s, flaggable_id: record.id).first_or_create!
20
+ end
21
+
22
+ # Remove a record from the given feature. If the feature is not found, an
23
+ # ActiveRecord::RecordNotFound will be raised.
24
+ #
25
+ # @example
26
+ # Detour::Feature.remove_record_from_feature user, :new_ui
27
+ #
28
+ # @param [ActiveRecord::Base] record A record to remove the feature from.
29
+ # @param [String,Symbol] feature_name The feature to be removed from the
30
+ # record.
31
+ def remove_record_from_feature(record, feature_name)
32
+ feature = find_by_name!(feature_name)
33
+ feature.flag_in_flags.where(flaggable_type: record.class.to_s, flaggable_id: record.id).destroy_all
34
+ end
35
+
36
+ # Opt the given record out of a feature. If the feature is not found, an
37
+ # ActiveRecord::RecordNotFound will be raised. An opt out ensures that no
38
+ # matter what, `record.rollout?(:rollout)` will always return false for any
39
+ # opted-out-of features.
40
+ #
41
+ # @param [ActiveRecord::Base] record A record to opt out of the feature.
42
+ # @param [String,Symbol] feature_name The feature to be opted out of.
43
+ #
44
+ # @example
45
+ # Detour::Feature.opt_record_out_of_feature user, :new_ui
46
+ #
47
+ # @return [Detour::OptOut] The
48
+ # {Detour::OptOut OptOut} created.
49
+ def opt_record_out_of_feature(record, feature_name)
50
+ feature = find_by_name!(feature_name)
51
+ feature.opt_out_flags.where(flaggable_type: record.class.to_s, flaggable_id: record.id).first_or_create!
52
+ end
53
+
54
+ # Remove any opt out for the given record out of a feature. If the feature
55
+ # is not found, an ActiveRecord::RecordNotFound will be raised.
56
+ #
57
+ # @example
58
+ # Detour::Feature.un_opt_record_out_of_feature user, :new_ui
59
+ #
60
+ # @param [ActiveRecord::Base] record A record to un-opt-out of the feature.
61
+ # @param [String,Symbol] feature_name The feature to be un-opted-out of.
62
+ def un_opt_record_out_of_feature(record, feature_name)
63
+ feature = find_by_name!(feature_name)
64
+ feature.opt_out_flags.where(flaggable_type: record.class.to_s, flaggable_id: record.id).destroy_all
65
+ end
66
+
67
+ # Add a group to the given feature. If the feature is not found, an
68
+ # ActiveRecord::RecordNotFound will be raised.
69
+ #
70
+ # @example
71
+ # Detour::Feature.add_group_to_feature "User", "admin", :delete_records
72
+ #
73
+ # @param [String] flaggable_type The class (as a string) that the group
74
+ # should be associated with.
75
+ # @param [String] group_name The name of the group to have the feature
76
+ # added to it.
77
+ # @param [String,Symbol] feature_name The feature to be added to the group.
78
+ #
79
+ # @return [Detour::Flag] The
80
+ # {Detour::Flag Flag} created.
81
+ def add_group_to_feature(flaggable_type, group_name, feature_name)
82
+ feature = find_by_name!(feature_name)
83
+ feature.group_flags.where(flaggable_type: flaggable_type, group_name: group_name).first_or_create!
84
+ end
85
+
86
+ # Remove a group from agiven feature. If the feature is not found, an
87
+ # ActiveRecord::RecordNotFound will be raised.
88
+ #
89
+ # @example
90
+ # Detour::Feature.remove_group_from_feature "User", "admin", :delete_records
91
+ #
92
+ # @param [String] flaggable_type The class (as a string) that the group should
93
+ # be removed from.
94
+ # @param [String] group_name The name of the group to have the feature
95
+ # removed from it.
96
+ # @param [String,Symbol] feature_name The feature to be removed from the
97
+ # group.
98
+ def remove_group_from_feature(flaggable_type, group_name, feature_name)
99
+ feature = find_by_name!(feature_name)
100
+ feature.group_flags.where(flaggable_type: flaggable_type, group_name: group_name).destroy_all
101
+ end
102
+
103
+ # Add a percentage of records to the given feature. If the feature is not
104
+ # found, an ActiveRecord::RecordNotFound will be raised.
105
+ #
106
+ # @example
107
+ # Detour::Feature.add_percentage_to_feature "User", 75, :delete_records
108
+ #
109
+ # @param [String] flaggable_type The class (as a string) that the percetnage
110
+ # should be associated with.
111
+ # @param [Integer] percentage The percentage of `flaggable_type` records
112
+ # that the feature will be available for.
113
+ # @param [String,Symbol] feature_name The feature to be added to the
114
+ # percentage of records.
115
+ #
116
+ # @return [Detour::Flag] The
117
+ # {Detour::Flag Flag} created.
118
+ def add_percentage_to_feature(flaggable_type, percentage, feature_name)
119
+ feature = find_by_name!(feature_name)
120
+
121
+ flag = feature.percentage_flags.where(flaggable_type: flaggable_type).first_or_initialize
122
+ flag.update_attributes!(percentage: percentage)
123
+ end
124
+
125
+ # Remove any percentage flags for the given feature. If the feature is not
126
+ # found, an ActiveRecord::RecordNotFound will be raised.
127
+ #
128
+ # @example
129
+ # Detour::Feature.remove_percentage_from_feature "User", delete_records
130
+ #
131
+ # @param [String] flaggable_type The class (as a string) that the percetnage
132
+ # should be removed from.
133
+ # @param [String,Symbol] feature_name The feature to have the percentage
134
+ # flag removed from.
135
+ def remove_percentage_from_feature(flaggable_type, feature_name)
136
+ feature = find_by_name!(feature_name)
137
+ feature.percentage_flags.where(flaggable_type: flaggable_type).destroy_all
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,73 @@
1
+ module Detour::Concerns
2
+ module Matchers
3
+ # Determines whether or not the given instance has had the feature rolled out
4
+ # to it either via direct flagging-in, percentage, or by group membership.
5
+ #
6
+ # @example
7
+ # feature.match?(current_user)
8
+ #
9
+ # @param [ActiveRecord::Base] instance A record to be tested for feature
10
+ # rollout.
11
+ #
12
+ # @return Whether or not the given instance has the feature rolled out to it.
13
+ def match?(instance)
14
+ match_id?(instance) || match_percentage?(instance) || match_groups?(instance)
15
+ end
16
+
17
+ # Determines whether or not the given instance has had the feature rolled out
18
+ # to it via direct flagging-in.
19
+ #
20
+ # @example
21
+ # feature.match_id?(current_user)
22
+ #
23
+ # @param [ActiveRecord::Base] instance A record to be tested for feature
24
+ # rollout.
25
+ #
26
+ # @return Whether or not the given instance has the feature rolled out to it
27
+ # via direct flagging-in.
28
+ def match_id?(instance)
29
+ flag_in_flags.where(flaggable_type: instance.class.to_s, flaggable_id: instance.id).any?
30
+ end
31
+
32
+ # Determines whether or not the given instance has had the feature rolled out
33
+ # to it via percentage.
34
+ #
35
+ # @example
36
+ # feature.match_percentage?(current_user)
37
+ #
38
+ # @param [ActiveRecord::Base] instance A record to be tested for feature
39
+ # rollout.
40
+ #
41
+ # @return Whether or not the given instance has the feature rolled out to it
42
+ # via direct percentage.
43
+ def match_percentage?(instance)
44
+ flag = percentage_flags.find(:first, conditions: ["flaggable_type = ?", instance.class.to_s])
45
+ percentage = flag ? flag.percentage : 0
46
+
47
+ instance.id % 10 < percentage / 10
48
+ end
49
+
50
+ # Determines whether or not the given instance has had the feature rolled out
51
+ # to it via group membership.
52
+ #
53
+ # @example
54
+ # feature.match_groups?(current_user)
55
+ #
56
+ # @param [ActiveRecord::Base] instance A record to be tested for feature
57
+ # rollout.
58
+ #
59
+ # @return Whether or not the given instance has the feature rolled out to it
60
+ # via direct group membership.
61
+ def match_groups?(instance)
62
+ klass = instance.class.to_s
63
+
64
+ return unless Detour.config.defined_groups[klass]
65
+
66
+ group_names = group_flags.find_all_by_flaggable_type(klass).collect(&:group_name)
67
+
68
+ Detour.config.defined_groups[klass].collect { |group_name, block|
69
+ block.call(instance) if group_names.include? group_name
70
+ }.any?
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,81 @@
1
+ # Represents an individual feature that may be rolled out to a set of records
2
+ # via individual flags, percentages, or defined groups.
3
+ class Detour::Feature < ActiveRecord::Base
4
+ include Detour::Concerns::Matchers
5
+ include Detour::Concerns::FlagActions
6
+
7
+ self.table_name = :detour_features
8
+
9
+ serialize :flag_in_counts, JSON
10
+ serialize :opt_out_counts, JSON
11
+
12
+ has_many :flag_in_flags
13
+ has_many :group_flags
14
+ has_many :percentage_flags
15
+ has_many :opt_out_flags
16
+ has_many :flags, dependent: :destroy
17
+
18
+ validates_presence_of :name
19
+ validates_uniqueness_of :name
20
+
21
+ attr_accessible :name
22
+
23
+ # Returns an instance variable intended to hold an array of the lines of code
24
+ # that this feature appears on.
25
+ #
26
+ # @return [Array<String>] The lines that this rollout appears on (if
27
+ # {Detour::Feature.with_lines} has already been called).
28
+ def lines
29
+ @lines ||= []
30
+ end
31
+
32
+ def to_s
33
+ name
34
+ end
35
+
36
+ # Returns the number of flag-ins for a given type.
37
+ #
38
+ # @example
39
+ # feature.flag_in_count_for("users")
40
+ #
41
+ # @return [Fixnum] The number of flag-ins for the given type.
42
+ def flag_in_count_for(type)
43
+ flag_in_counts[type] || 0
44
+ end
45
+
46
+ # Returns the number of opt-outs for a given type.
47
+ #
48
+ # @example
49
+ # feature.opt_out_count_for("users")
50
+ #
51
+ # @return [Fixnum] The number of opt-outs for the given type.
52
+ def opt_out_count_for(type)
53
+ opt_out_counts[type] || 0
54
+ end
55
+
56
+ # Return an array of both every feature in the database as well as every
57
+ # feature that is checked for in `@grep_dirs`. Features that are checked
58
+ # for but not persisted will be returned as unpersisted instances of this
59
+ # class. Each instance returned will have its `@lines` set to an array
60
+ # containing every line in `@grep_dirs` where it is checked for.
61
+ #
62
+ # @return [Array<Detour::Feature>] Every persisted and
63
+ # checked-for feature.
64
+ def self.with_lines
65
+ hash = all.each_with_object({}) { |feature, hash| hash[feature.name] = feature }
66
+
67
+ Dir[*Detour.config.grep_dirs].each do |path|
68
+ next unless File.file? path
69
+
70
+ File.open path do |file|
71
+ file.each_line.with_index(1) do |line, i|
72
+ line.scan(/\.has_feature\?\s*\(*:(\w+)/).each do |match, _|
73
+ (hash[match] ||= new(name: match)).lines << "#{path}#L#{i}"
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ hash.values.sort_by(&:name)
80
+ end
81
+ end
@@ -6,8 +6,14 @@ class Detour::Flag < ActiveRecord::Base
6
6
 
7
7
  belongs_to :feature
8
8
 
9
- validates :feature_id, presence: true
10
- validates :flaggable_type, presence: true
9
+ validates_presence_of :feature
10
+ validates_presence_of :flaggable_type
11
11
 
12
12
  attr_accessible :flaggable_type
13
+
14
+ private
15
+
16
+ def flag_type
17
+ self.class.to_s.underscore.gsub("detour/", "").split("_")[0..-2].join("_")
18
+ end
13
19
  end
@@ -0,0 +1,12 @@
1
+ # An individual record of a certain type may be flagged into a feature with
2
+ # this class.
3
+ class Detour::FlagInFlag < Detour::Flag
4
+ include Detour::Concerns::CountableFlag
5
+
6
+ belongs_to :flaggable, polymorphic: true
7
+
8
+ validates_presence_of :flaggable
9
+ validates_uniqueness_of :feature_id, scope: [:flaggable_type, :flaggable_id]
10
+
11
+ attr_accessible :flaggable
12
+ end
@@ -0,0 +1,13 @@
1
+ # A group of flaggable records of a given class may be flagged into a feature
2
+ # with this class.
3
+ class Detour::GroupFlag < Detour::Flag
4
+ validates_presence_of :group_name
5
+ validates_uniqueness_of :feature_id, scope: [:flaggable_type, :group_name]
6
+
7
+ attr_writer :to_keep
8
+ attr_accessible :group_name
9
+
10
+ def to_keep
11
+ @to_keep || (!marked_for_destruction? && !new_record?)
12
+ end
13
+ end
@@ -1,10 +1,12 @@
1
1
  # Ensures that a feature will never be available to the associated record,
2
2
  # even in the case of, for example, a 100% flag.
3
3
  class Detour::OptOutFlag < Detour::Flag
4
+ include Detour::Concerns::CountableFlag
5
+
4
6
  belongs_to :flaggable, polymorphic: true
5
7
 
6
- validates :flaggable_id, presence: true
7
- validates :feature_id, uniqueness: { scope: [:flaggable_type, :flaggable_id] }
8
+ validates_presence_of :flaggable
9
+ validates_uniqueness_of :feature_id, scope: [:flaggable_type, :flaggable_id]
8
10
 
9
11
  attr_accessible :flaggable
10
12
  end
@@ -0,0 +1,9 @@
1
+ # A percentage of flaggable records of a given class may be flagged into a feature
2
+ # with this class.
3
+ class Detour::PercentageFlag < Detour::Flag
4
+ validates_presence_of :percentage
5
+ validates_numericality_of :percentage, greater_than: 0, less_than_or_equal_to: 100
6
+ validates_uniqueness_of :feature_id, scope: :flaggable_type
7
+
8
+ attr_accessible :percentage
9
+ end
File without changes
@@ -0,0 +1,11 @@
1
+ <div class="panel panel-danger">
2
+ <div class="panel-heading">Whoops! There were some errors saving your feature:</div>
3
+
4
+ <div class="panel-body">
5
+ <ul>
6
+ <% @feature.errors.full_messages.each do |msg| %>
7
+ <li><%= msg %></li>
8
+ <% end %>
9
+ </ul>
10
+ </div>
11
+ </div>
@@ -0,0 +1 @@
1
+ <div class="alert alert-success"><%= flash.notice %></div>
@@ -0,0 +1,5 @@
1
+ if ($(".modal-body .panel-danger").length) {
2
+ $("#create-feature .modal-body .panel-danger").replaceWith("<%= j render "errors" %>");
3
+ } else {
4
+ $("#create-feature .modal-body").prepend("<%= j render "errors" %>");
5
+ }
@@ -0,0 +1 @@
1
+ location.reload();
@@ -0,0 +1,11 @@
1
+ <div class="panel panel-danger">
2
+ <div class="panel-heading">Whoops! There were some errors saving your feature:</div>
3
+
4
+ <div class="panel-body">
5
+ <ul>
6
+ <% @errors.each do |error| %>
7
+ <li><%= error %></li>
8
+ <% end %>
9
+ </ul>
10
+ </div>
11
+ </div>