pin_flags 0.1.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +244 -0
- data/Rakefile +8 -0
- data/app/assets/javascripts/pin_flags/alpine.min.js +5 -0
- data/app/assets/stylesheets/pin_flags/application.css +25 -0
- data/app/assets/stylesheets/pin_flags/bulma.min.css +21564 -0
- data/app/assets/stylesheets/pin_flags/forms.css +8 -0
- data/app/controllers/pin_flags/application_controller.rb +6 -0
- data/app/controllers/pin_flags/feature_tags/exports_controller.rb +18 -0
- data/app/controllers/pin_flags/feature_tags/feature_subscriptions_controller.rb +138 -0
- data/app/controllers/pin_flags/feature_tags/imports_controller.rb +65 -0
- data/app/controllers/pin_flags/feature_tags_controller.rb +141 -0
- data/app/helpers/pin_flags/application_helper.rb +20 -0
- data/app/helpers/pin_flags/feature_tags/feature_subscriptions_helper.rb +23 -0
- data/app/helpers/pin_flags/feature_tags/imports_helper.rb +7 -0
- data/app/helpers/pin_flags/feature_tags_helper.rb +12 -0
- data/app/jobs/pin_flags/application_job.rb +4 -0
- data/app/mailers/pin_flags/application_mailer.rb +6 -0
- data/app/models/concerns/pin_flags/feature_taggable.rb +39 -0
- data/app/models/pin_flags/application_record.rb +5 -0
- data/app/models/pin_flags/feature_subscription/bulk_processor.rb +80 -0
- data/app/models/pin_flags/feature_subscription.rb +21 -0
- data/app/models/pin_flags/feature_tag/cacheable.rb +44 -0
- data/app/models/pin_flags/feature_tag.rb +98 -0
- data/app/models/pin_flags/page.rb +48 -0
- data/app/views/layouts/pin_flags/_flash.html.erb +6 -0
- data/app/views/layouts/pin_flags/_modal.html.erb +41 -0
- data/app/views/layouts/pin_flags/application.html.erb +34 -0
- data/app/views/pin_flags/feature_tags/_empty_feature_tags_table.html.erb +7 -0
- data/app/views/pin_flags/feature_tags/_feature_tags_table.html.erb +82 -0
- data/app/views/pin_flags/feature_tags/_feature_tags_table_row.html.erb +50 -0
- data/app/views/pin_flags/feature_tags/_new_feature_subscription_link.html.erb +8 -0
- data/app/views/pin_flags/feature_tags/_new_feature_tag_link.html.erb +6 -0
- data/app/views/pin_flags/feature_tags/create.turbo_stream.erb +7 -0
- data/app/views/pin_flags/feature_tags/destroy.turbo_stream.erb +7 -0
- data/app/views/pin_flags/feature_tags/feature_subscriptions/_empty_feature_subscriptions_table.html.erb +7 -0
- data/app/views/pin_flags/feature_tags/feature_subscriptions/_feature_subscriptions_table.html.erb +83 -0
- data/app/views/pin_flags/feature_tags/feature_subscriptions/_feature_subscriptions_table_row.html.erb +37 -0
- data/app/views/pin_flags/feature_tags/feature_subscriptions/create.turbo_stream.erb +10 -0
- data/app/views/pin_flags/feature_tags/feature_subscriptions/destroy.turbo_stream.erb +7 -0
- data/app/views/pin_flags/feature_tags/feature_subscriptions/new.html.erb +83 -0
- data/app/views/pin_flags/feature_tags/imports/_form.html.erb +66 -0
- data/app/views/pin_flags/feature_tags/index.html.erb +123 -0
- data/app/views/pin_flags/feature_tags/new.html.erb +34 -0
- data/app/views/pin_flags/feature_tags/show.html.erb +89 -0
- data/app/views/pin_flags/feature_tags/update.html.erb +2 -0
- data/app/views/pin_flags/feature_tags/update.turbo_stream.erb +3 -0
- data/app/views/pin_flags/shared/_error_messages.html.erb +11 -0
- data/config/routes.rb +12 -0
- data/db/migrate/20250626042529_create_feature_tag.rb +12 -0
- data/db/migrate/20250626050146_create_feature_subscriptions.rb +15 -0
- data/lib/generators/pin_flags/install/install_generator.rb +21 -0
- data/lib/generators/pin_flags/install/templates/create_feature_subscriptions.rb +14 -0
- data/lib/generators/pin_flags/install/templates/create_feature_tag.rb +12 -0
- data/lib/pin_flags/engine.rb +13 -0
- data/lib/pin_flags/version.rb +3 -0
- data/lib/pin_flags.rb +13 -0
- data/lib/tasks/pin_flags_tasks.rake +4 -0
- metadata +192 -0
@@ -0,0 +1,18 @@
|
|
1
|
+
module PinFlags
|
2
|
+
module FeatureTags
|
3
|
+
class ExportsController < ApplicationController
|
4
|
+
def index
|
5
|
+
respond_to do |format|
|
6
|
+
format.json { send_data(PinFlags::FeatureTag.export_as_json, filename: export_filename) }
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def export_filename
|
13
|
+
timestamp = Time.zone.now.strftime("%Y%m%d%H%M%S")
|
14
|
+
"pin_flags_feature_tags_#{timestamp}.json"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
module PinFlags
|
2
|
+
module FeatureTags
|
3
|
+
class FeatureSubscriptionsController < ApplicationController
|
4
|
+
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
|
5
|
+
|
6
|
+
before_action :set_feature_tag
|
7
|
+
before_action :set_current_page, only: %i[create destroy]
|
8
|
+
before_action :set_filter_param, only: %i[create destroy]
|
9
|
+
before_action :set_feature_taggable_type, only: %i[create destroy]
|
10
|
+
before_action :set_feature_subscription, only: %i[destroy]
|
11
|
+
|
12
|
+
def new
|
13
|
+
@feature_subscription = @feature_tag.feature_subscriptions.new
|
14
|
+
end
|
15
|
+
|
16
|
+
def create
|
17
|
+
if feature_subscription_params[:bulk_upload] == "1"
|
18
|
+
create_feature_subscriptions_in_bulk
|
19
|
+
else
|
20
|
+
create_single_feature_subscription
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def destroy
|
25
|
+
@feature_subscription.destroy
|
26
|
+
|
27
|
+
respond_to do |format|
|
28
|
+
format.html { redirect_to @feature_tag, notice: "Feature Subscription was successfully destroyed." }
|
29
|
+
format.turbo_stream
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def set_feature_tag
|
36
|
+
@feature_tag = PinFlags::FeatureTag.find_by(id: params[:feature_tag_id])
|
37
|
+
raise ActiveRecord::RecordNotFound if @feature_tag.blank?
|
38
|
+
end
|
39
|
+
|
40
|
+
def set_current_page
|
41
|
+
@current_page = params.fetch(:page, 1).to_i
|
42
|
+
end
|
43
|
+
|
44
|
+
def set_filter_param
|
45
|
+
@filter_param = params.fetch(:filter, nil)
|
46
|
+
end
|
47
|
+
|
48
|
+
def set_feature_taggable_type
|
49
|
+
@feature_taggable_type = params.fetch(:feature_taggable_type, nil)
|
50
|
+
end
|
51
|
+
|
52
|
+
def feature_subscription_params
|
53
|
+
params.expect(feature_subscription: [ :feature_taggable_type, :feature_taggable_id, :bulk_upload ])
|
54
|
+
end
|
55
|
+
|
56
|
+
def set_feature_subscription
|
57
|
+
@feature_subscription = @feature_tag.feature_subscriptions.find_by(id: params[:id])
|
58
|
+
raise ActiveRecord::RecordNotFound if @feature_subscription.blank?
|
59
|
+
end
|
60
|
+
|
61
|
+
def record_not_found
|
62
|
+
alert_message = @feature_tag.blank? ? "Feature Tag not found." : "Feature Subscription not found."
|
63
|
+
redirect_to pin_flags.feature_tags_path, alert: alert_message
|
64
|
+
end
|
65
|
+
|
66
|
+
def create_single_feature_subscription
|
67
|
+
@feature_subscription = @feature_tag.feature_subscriptions.new(feature_subscription_params.except(:bulk_upload))
|
68
|
+
|
69
|
+
if @feature_subscription.save
|
70
|
+
set_paginated_feature_subscriptions
|
71
|
+
|
72
|
+
respond_to do |format|
|
73
|
+
format.html { redirect_to pin_flags.feature_tag_path(@feature_tag, filter: @filter_param, feature_taggable_type: @feature_taggable_type), notice: "Feature Subscription was successfully created." }
|
74
|
+
format.turbo_stream
|
75
|
+
end
|
76
|
+
else
|
77
|
+
render :new, status: :unprocessable_content
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def create_feature_subscriptions_in_bulk
|
82
|
+
feature_taggable_ids = feature_subscription_params[:feature_taggable_id].split(",")
|
83
|
+
|
84
|
+
if PinFlags::FeatureSubscription.create_in_bulk(
|
85
|
+
feature_tag: @feature_tag,
|
86
|
+
feature_taggable_type: feature_subscription_params[:feature_taggable_type],
|
87
|
+
feature_taggable_ids: feature_taggable_ids
|
88
|
+
)
|
89
|
+
handle_feature_subscriptions_in_bulk_success
|
90
|
+
else
|
91
|
+
handle_feature_subscriptions_in_bulk_failure
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def handle_feature_subscriptions_in_bulk_success
|
96
|
+
set_paginated_feature_subscriptions
|
97
|
+
|
98
|
+
respond_to do |format|
|
99
|
+
format.html do
|
100
|
+
redirect_to pin_flags.feature_tag_path(@feature_tag, filter: @filter_param, feature_taggable_type: @feature_taggable_type), notice: "Feature Subscriptions were successfully created."
|
101
|
+
end
|
102
|
+
format.turbo_stream
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def handle_feature_subscriptions_in_bulk_failure
|
107
|
+
alert_message = "One or more Feature Taggable IDs are invalid."
|
108
|
+
flash.now[:alert] = alert_message
|
109
|
+
@feature_subscription = @feature_tag.feature_subscriptions.new(
|
110
|
+
feature_taggable_type: feature_subscription_params[:feature_taggable_type],
|
111
|
+
feature_taggable_id: feature_subscription_params[:feature_taggable_id]
|
112
|
+
)
|
113
|
+
@feature_subscription.errors.add(:feature_taggable_id, alert_message)
|
114
|
+
render :new, status: :unprocessable_content
|
115
|
+
end
|
116
|
+
|
117
|
+
def fetch_filtered_feature_subscriptions
|
118
|
+
feature_subscriptions = @feature_tag.feature_subscriptions.order(created_at: :desc)
|
119
|
+
|
120
|
+
if @feature_taggable_type.present?
|
121
|
+
feature_subscriptions = feature_subscriptions.where(feature_taggable_type: @feature_taggable_type)
|
122
|
+
end
|
123
|
+
|
124
|
+
if @filter_param.present?
|
125
|
+
feature_subscriptions = feature_subscriptions.where(feature_taggable_id: @filter_param.strip)
|
126
|
+
end
|
127
|
+
|
128
|
+
feature_subscriptions
|
129
|
+
end
|
130
|
+
|
131
|
+
def set_paginated_feature_subscriptions
|
132
|
+
filtered_subscriptions = fetch_filtered_feature_subscriptions
|
133
|
+
@paginator = PinFlags::Page.new(filtered_subscriptions, page: @current_page, page_size: PinFlags::FeatureTagsController::PER_PAGE)
|
134
|
+
@feature_subscriptions = @paginator.records
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
module PinFlags
|
2
|
+
module FeatureTags
|
3
|
+
class ImportsController < ApplicationController
|
4
|
+
MAX_FILE_SIZE ||= 2.megabytes
|
5
|
+
|
6
|
+
def create
|
7
|
+
if valid_json_file? && import_successful?
|
8
|
+
respond_with_success
|
9
|
+
else
|
10
|
+
respond_with_error
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def import_params
|
17
|
+
params.permit(:json_file, :authenticity_token, :commit)
|
18
|
+
end
|
19
|
+
|
20
|
+
def valid_json_file?
|
21
|
+
return false if import_params[:json_file].blank?
|
22
|
+
|
23
|
+
file = import_params[:json_file]
|
24
|
+
correct_type = file.content_type == "application/json"
|
25
|
+
correct_size = file.size <= MAX_FILE_SIZE
|
26
|
+
|
27
|
+
correct_type && correct_size
|
28
|
+
end
|
29
|
+
|
30
|
+
def import_successful?
|
31
|
+
file_contents = import_params[:json_file].read
|
32
|
+
PinFlags::FeatureTag.import_from_json(file_contents)
|
33
|
+
rescue PinFlags::FeatureTagImportError => e
|
34
|
+
Rails.logger.error(e.message)
|
35
|
+
false
|
36
|
+
end
|
37
|
+
|
38
|
+
def respond_with_success
|
39
|
+
respond_to do |format|
|
40
|
+
format.html { redirect_to feature_tags_path, notice: "Feature tags were successfully imported." }
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def error_message
|
45
|
+
if import_params[:json_file].blank?
|
46
|
+
"No file uploaded."
|
47
|
+
elsif import_params[:json_file].content_type != "application/json"
|
48
|
+
"File must be JSON format."
|
49
|
+
elsif import_params[:json_file].size > MAX_FILE_SIZE
|
50
|
+
"File size must be less than #{MAX_FILE_SIZE / 1.megabyte}MB."
|
51
|
+
else
|
52
|
+
"Invalid JSON file or import failed."
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def respond_with_error
|
57
|
+
message = error_message
|
58
|
+
|
59
|
+
respond_to do |format|
|
60
|
+
format.html { redirect_to feature_tags_path, alert: message }
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
module PinFlags
|
2
|
+
class FeatureTagsController < ApplicationController
|
3
|
+
rescue_from ActiveRecord::RecordNotFound, with: :handle_record_not_found
|
4
|
+
|
5
|
+
before_action :set_filter_param, only: %i[index show]
|
6
|
+
before_action :set_enabled_param, only: %i[index show]
|
7
|
+
before_action :set_subscriptions_param, only: %i[index]
|
8
|
+
before_action :set_current_page, only: %i[index show create]
|
9
|
+
before_action :set_feature_taggable_type, only: %i[show]
|
10
|
+
before_action :set_feature_tag, only: %i[show update destroy]
|
11
|
+
|
12
|
+
PER_PAGE ||= 10
|
13
|
+
|
14
|
+
def index
|
15
|
+
@paginator = PinFlags::Page.new(fetch_feature_tags, page: @current_page, page_size: PER_PAGE)
|
16
|
+
@feature_tags = @paginator.records
|
17
|
+
|
18
|
+
# Handle overflow (when page number is too high)
|
19
|
+
if @paginator.index > @paginator.pages_count && @paginator.pages_count&.positive?
|
20
|
+
redirect_to feature_tags_path(search: @filter_param, enabled: @enabled_param, subscriptions: @subscriptions_param)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def show
|
25
|
+
@paginator = PinFlags::Page.new(fetch_feature_subscriptions, page: @current_page, page_size: PER_PAGE)
|
26
|
+
@feature_subscriptions = @paginator.records
|
27
|
+
|
28
|
+
# Handle overflow for feature subscriptions
|
29
|
+
if @paginator.index > @paginator.pages_count && @paginator.pages_count&.positive?
|
30
|
+
redirect_to feature_tag_path(@feature_tag, feature_taggable_type: @feature_taggable_type, filter: @filter_param)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def new
|
35
|
+
@feature_tag = PinFlags::FeatureTag.new
|
36
|
+
end
|
37
|
+
|
38
|
+
def create
|
39
|
+
@feature_tag = PinFlags::FeatureTag.new(feature_tag_params)
|
40
|
+
|
41
|
+
if @feature_tag.save
|
42
|
+
@feature_tags = fetch_feature_tags
|
43
|
+
@paginator = PinFlags::Page.new(@feature_tags, page: @current_page, page_size: PER_PAGE)
|
44
|
+
@feature_tags = @paginator.records
|
45
|
+
handle_success(:create)
|
46
|
+
else
|
47
|
+
render :new, status: :unprocessable_content
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def update
|
52
|
+
if @feature_tag.update(feature_tag_params)
|
53
|
+
handle_success(:update)
|
54
|
+
else
|
55
|
+
render :index, status: :unprocessable_content
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def destroy
|
60
|
+
@feature_tag.destroy
|
61
|
+
handle_success(:delete)
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def set_current_page
|
67
|
+
@current_page = params.fetch(:page, 1).to_i
|
68
|
+
end
|
69
|
+
|
70
|
+
def set_feature_tag
|
71
|
+
@feature_tag = PinFlags::FeatureTag.find(params[:id])
|
72
|
+
end
|
73
|
+
|
74
|
+
def feature_tag_params
|
75
|
+
params.expect(feature_tag: [ :name, :enabled ])
|
76
|
+
end
|
77
|
+
|
78
|
+
def set_filter_param
|
79
|
+
@filter_param = params.fetch(:filter, nil)
|
80
|
+
end
|
81
|
+
|
82
|
+
def set_enabled_param
|
83
|
+
@enabled_param = params.fetch(:enabled, nil)
|
84
|
+
end
|
85
|
+
|
86
|
+
def set_subscriptions_param
|
87
|
+
@subscriptions_param = params.fetch(:subscriptions, nil)
|
88
|
+
end
|
89
|
+
|
90
|
+
def set_feature_taggable_type
|
91
|
+
@feature_taggable_type = params.fetch(:feature_taggable_type, nil)
|
92
|
+
end
|
93
|
+
|
94
|
+
def fetch_feature_tags
|
95
|
+
scope = FeatureTag.all.includes(:feature_subscriptions).order(created_at: :desc)
|
96
|
+
scope = scope.with_name_like(@filter_param) if @filter_param.present?
|
97
|
+
scope = filter_by_enabled_status(scope) if @enabled_param.present?
|
98
|
+
scope = filter_by_subscriptions_status(scope) if @subscriptions_param.present?
|
99
|
+
scope
|
100
|
+
end
|
101
|
+
|
102
|
+
def fetch_feature_subscriptions
|
103
|
+
feature_subscriptions = @feature_tag.feature_subscriptions.order(created_at: :desc)
|
104
|
+
|
105
|
+
if @feature_taggable_type.present?
|
106
|
+
feature_subscriptions = feature_subscriptions.where(feature_taggable_type: @feature_taggable_type)
|
107
|
+
end
|
108
|
+
|
109
|
+
if @filter_param.present?
|
110
|
+
feature_subscriptions = feature_subscriptions.where(feature_taggable_id: @filter_param.strip)
|
111
|
+
end
|
112
|
+
|
113
|
+
feature_subscriptions
|
114
|
+
end
|
115
|
+
|
116
|
+
|
117
|
+
def filter_by_enabled_status(scope)
|
118
|
+
enabled = ActiveModel::Type::Boolean.new.cast(@enabled_param)
|
119
|
+
enabled ? scope.enabled : scope.disabled
|
120
|
+
end
|
121
|
+
|
122
|
+
def filter_by_subscriptions_status(scope)
|
123
|
+
has_subscriptions = ActiveModel::Type::Boolean.new.cast(@subscriptions_param)
|
124
|
+
has_subscriptions ? scope.joins(:feature_subscriptions).distinct : scope.left_joins(:feature_subscriptions).where(feature_subscriptions: { id: nil })
|
125
|
+
end
|
126
|
+
|
127
|
+
def handle_success(action)
|
128
|
+
flash_notice = "Feature tag was successfully #{action}d."
|
129
|
+
|
130
|
+
respond_to do |format|
|
131
|
+
format.html { redirect_to feature_tags_path, notice: flash_notice }
|
132
|
+
format.turbo_stream { flash.now[:notice] = flash_notice }
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def handle_record_not_found
|
137
|
+
flash[:error] = "Feature tag not found."
|
138
|
+
redirect_to pin_flags.feature_tags_path, status: :see_other
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module PinFlags
|
2
|
+
module ApplicationHelper
|
3
|
+
def flash_class(type)
|
4
|
+
case type.to_s
|
5
|
+
when "notice", "success"
|
6
|
+
"is-success"
|
7
|
+
when "alert", "error"
|
8
|
+
"is-danger"
|
9
|
+
when "warning"
|
10
|
+
"is-warning"
|
11
|
+
else
|
12
|
+
"is-info"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def page_title
|
17
|
+
content_for?(:title) ? content_for(:title) : "Dashboard"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module PinFlags
|
2
|
+
module FeatureTags
|
3
|
+
module FeatureSubscriptionsHelper
|
4
|
+
def display_feature_subscription_table_turbo_frame_row_id(feature_subscription)
|
5
|
+
"feature_subscriptions_table_row_#{dom_id(feature_subscription)}"
|
6
|
+
end
|
7
|
+
|
8
|
+
def display_feature_subscription_row_delete_confirmation(feature_subscription)
|
9
|
+
taggable_type = strip_tags(feature_subscription.feature_taggable_type)
|
10
|
+
taggable_id = feature_subscription.feature_taggable_id
|
11
|
+
|
12
|
+
"Are you sure you want to delete the feature subscription for '#{taggable_type}' with ID '#{taggable_id}'?"
|
13
|
+
end
|
14
|
+
|
15
|
+
def display_feature_subscription_delete_button_aria_label(feature_subscription)
|
16
|
+
feature_taggable_type = strip_tags(feature_subscription.feature_taggable_type)
|
17
|
+
feature_taggable_id = feature_subscription.feature_taggable_id
|
18
|
+
|
19
|
+
"Delete Feature Subscription for #{feature_taggable_type} with ID #{feature_taggable_id}"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
module PinFlags
|
2
|
+
module FeatureTagsHelper
|
3
|
+
def display_feature_tag_table_turbo_frame_row_id(feature_tag)
|
4
|
+
"feature_tags_table_row_#{dom_id(feature_tag)}"
|
5
|
+
end
|
6
|
+
|
7
|
+
def display_feature_tag_row_delete_confirmation(feature_tag)
|
8
|
+
sanitized_name = strip_tags(feature_tag.name)
|
9
|
+
"Are you sure you want to delete the feature tag '#{sanitized_name}'?"
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module PinFlags
|
2
|
+
module FeatureTaggable
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
has_many :feature_subscriptions,
|
7
|
+
class_name: "PinFlags::FeatureSubscription",
|
8
|
+
as: :feature_taggable,
|
9
|
+
dependent: :destroy
|
10
|
+
|
11
|
+
has_many :feature_tags,
|
12
|
+
class_name: "PinFlags::FeatureTag",
|
13
|
+
through: :feature_subscriptions
|
14
|
+
end
|
15
|
+
|
16
|
+
def feature_tag_pin(tag_name)
|
17
|
+
feature_tags << PinFlags::FeatureTag.find_or_create_by(name: tag_name)
|
18
|
+
rescue ActiveRecord::RecordNotUnique
|
19
|
+
feature_tags
|
20
|
+
end
|
21
|
+
|
22
|
+
def feature_tag_unpin(tag_name)
|
23
|
+
existing_tag = feature_tags.find_by(name: tag_name)
|
24
|
+
feature_tags.delete(existing_tag) if existing_tag
|
25
|
+
end
|
26
|
+
|
27
|
+
def feature_tag?(tag_name)
|
28
|
+
feature_tags.exists?(name: tag_name)
|
29
|
+
end
|
30
|
+
|
31
|
+
def feature_tag_enabled?(tag_name)
|
32
|
+
feature_tags.enabled.exists?(name: tag_name)
|
33
|
+
end
|
34
|
+
|
35
|
+
def feature_tag_disabled?(tag_name)
|
36
|
+
feature_tags.disabled.exists?(name: tag_name)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
class PinFlags::FeatureSubscription::BulkProcessor
|
2
|
+
include ActiveModel::Model
|
3
|
+
include ActiveModel::Attributes
|
4
|
+
include ActiveModel::Validations
|
5
|
+
|
6
|
+
attribute :feature_tag
|
7
|
+
attribute :feature_taggable_type, :string
|
8
|
+
|
9
|
+
attr_accessor :feature_taggable_ids
|
10
|
+
|
11
|
+
validates :feature_tag, presence: true
|
12
|
+
validates :feature_taggable_type, presence: true
|
13
|
+
validates :feature_taggable_ids, presence: true
|
14
|
+
validate :validate_feature_taggable_type_is_valid_class
|
15
|
+
validate :validate_feature_taggable_ids_exist
|
16
|
+
|
17
|
+
def initialize(attributes = {})
|
18
|
+
super
|
19
|
+
@feature_taggable_ids = Array(attributes[:feature_taggable_ids]) if attributes[:feature_taggable_ids]
|
20
|
+
end
|
21
|
+
|
22
|
+
def process
|
23
|
+
return false unless valid?
|
24
|
+
|
25
|
+
# Prepare records for upsert_all
|
26
|
+
records = sanitized_feature_taggable_ids.map do |feature_taggable_id|
|
27
|
+
{
|
28
|
+
feature_tag_id: feature_tag.id,
|
29
|
+
feature_taggable_type: feature_taggable_type,
|
30
|
+
feature_taggable_id: feature_taggable_id,
|
31
|
+
created_at: Time.current,
|
32
|
+
updated_at: Time.current
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
begin
|
37
|
+
# Use upsert_all to insert or update records in bulk
|
38
|
+
PinFlags::FeatureSubscription.upsert_all(
|
39
|
+
records,
|
40
|
+
unique_by: [ :feature_tag_id, :feature_taggable_type, :feature_taggable_id ]
|
41
|
+
)
|
42
|
+
true
|
43
|
+
rescue ActiveRecord::RecordInvalid, ActiveRecord::StatementInvalid => _e
|
44
|
+
false
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def sanitized_feature_taggable_ids
|
51
|
+
@sanitized_feature_taggable_ids ||= Array(feature_taggable_ids).map(&:strip)
|
52
|
+
end
|
53
|
+
|
54
|
+
def feature_taggable_class
|
55
|
+
@feature_taggable_class ||= feature_taggable_type.constantize
|
56
|
+
rescue NameError
|
57
|
+
nil
|
58
|
+
end
|
59
|
+
|
60
|
+
def validate_feature_taggable_type_is_valid_class
|
61
|
+
return if feature_taggable_type.blank?
|
62
|
+
|
63
|
+
if feature_taggable_class.nil?
|
64
|
+
errors.add(:feature_taggable_type, "is not a valid class")
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def validate_feature_taggable_ids_exist
|
69
|
+
return if feature_taggable_ids.blank? || feature_taggable_class.nil?
|
70
|
+
|
71
|
+
existing_ids = feature_taggable_class.where(id: sanitized_feature_taggable_ids)
|
72
|
+
.pluck(:id)
|
73
|
+
.map(&:to_s)
|
74
|
+
invalid_ids = sanitized_feature_taggable_ids - existing_ids
|
75
|
+
|
76
|
+
if invalid_ids.any?
|
77
|
+
errors.add(:feature_taggable_ids, "contain non-existent IDs: #{invalid_ids.join(', ')}")
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module PinFlags
|
2
|
+
class FeatureSubscription < ApplicationRecord
|
3
|
+
self.table_name = "pin_flags_feature_subscriptions"
|
4
|
+
|
5
|
+
belongs_to :feature_tag
|
6
|
+
belongs_to :feature_taggable, polymorphic: true
|
7
|
+
|
8
|
+
validates :feature_taggable_id, uniqueness: {
|
9
|
+
scope: %i[feature_tag_id feature_taggable_type],
|
10
|
+
message: "already has this feature tag applied"
|
11
|
+
}
|
12
|
+
|
13
|
+
def self.create_in_bulk(feature_tag:, feature_taggable_type:, feature_taggable_ids:)
|
14
|
+
BulkProcessor.new(
|
15
|
+
feature_tag: feature_tag,
|
16
|
+
feature_taggable_type: feature_taggable_type,
|
17
|
+
feature_taggable_ids: feature_taggable_ids
|
18
|
+
).process
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
module PinFlags::FeatureTag::Cacheable
|
2
|
+
extend ActiveSupport::Concern
|
3
|
+
|
4
|
+
included do
|
5
|
+
after_commit :clear_cache
|
6
|
+
end
|
7
|
+
|
8
|
+
class_methods do
|
9
|
+
def clear_tag_cache(tag_name)
|
10
|
+
normalized_name = normalize_tag_name(tag_name)
|
11
|
+
Rails.cache.delete("#{PinFlags.cache_prefix}:enabled:#{normalized_name}")
|
12
|
+
end
|
13
|
+
|
14
|
+
def clear_all_cache
|
15
|
+
Rails.cache.delete_matched("#{PinFlags.cache_prefix}:*")
|
16
|
+
end
|
17
|
+
|
18
|
+
def enabled?(tag_name)
|
19
|
+
normalized_name = normalize_tag_name(tag_name)
|
20
|
+
cache_key = "#{PinFlags.cache_prefix}:enabled:#{normalized_name}"
|
21
|
+
|
22
|
+
Rails.cache.fetch(cache_key, expires_in: PinFlags.cache_expiry) do
|
23
|
+
enabled.exists?(name: normalized_name)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def disabled?(tag_name)
|
28
|
+
!enabled?(tag_name)
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def cache_key_for(tag_name)
|
34
|
+
normalized_name = normalize_tag_name(tag_name)
|
35
|
+
"#{PinFlags.cache_prefix}:enabled:#{normalized_name}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def clear_cache
|
42
|
+
self.class.clear_tag_cache(name)
|
43
|
+
end
|
44
|
+
end
|