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,98 @@
|
|
1
|
+
module PinFlags
|
2
|
+
class FeatureTagImportError < StandardError; end
|
3
|
+
|
4
|
+
class FeatureTag < ApplicationRecord
|
5
|
+
include PinFlags::FeatureTag::Cacheable
|
6
|
+
|
7
|
+
self.table_name = "pin_flags_feature_tags"
|
8
|
+
|
9
|
+
has_many :feature_subscriptions, dependent: :destroy
|
10
|
+
has_many :feature_taggables, through: :feature_subscriptions
|
11
|
+
|
12
|
+
MIN_NAME_LENGTH ||= 3
|
13
|
+
MAX_NAME_LENGTH ||= 64
|
14
|
+
|
15
|
+
validates :name, presence: true, uniqueness: true, length: { minimum: MIN_NAME_LENGTH, maximum: MAX_NAME_LENGTH }
|
16
|
+
validates :enabled, inclusion: { in: [ true, false ] }
|
17
|
+
|
18
|
+
normalizes :name, with: ->(value) { value.strip.downcase.parameterize.underscore }
|
19
|
+
|
20
|
+
scope :enabled, -> { where(enabled: true) }
|
21
|
+
scope :disabled, -> { where(enabled: false) }
|
22
|
+
scope :with_name_like, ->(name) {
|
23
|
+
where("LOWER(name) LIKE LOWER(?)", "%#{normalize_tag_name(name)}%")
|
24
|
+
}
|
25
|
+
|
26
|
+
def self.enable(tag_name)
|
27
|
+
normalized_name = normalize_tag_name(tag_name)
|
28
|
+
result = find_or_create_by(name: normalized_name).update!(enabled: true)
|
29
|
+
clear_tag_cache(normalized_name)
|
30
|
+
result
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.disable(tag_name)
|
34
|
+
normalized_name = normalize_tag_name(tag_name)
|
35
|
+
result = find_or_create_by(name: normalized_name).update!(enabled: false)
|
36
|
+
clear_tag_cache(normalized_name)
|
37
|
+
result
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.enabled_for_subscriber?(tag_name, feature_taggable)
|
41
|
+
normalized_name = normalize_tag_name(tag_name)
|
42
|
+
return false unless enabled?(normalized_name)
|
43
|
+
|
44
|
+
feature_taggable.feature_tag_enabled?(normalized_name)
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.feature_taggable_models
|
48
|
+
Rails.application.eager_load! unless Rails.application.config.eager_load
|
49
|
+
|
50
|
+
# Find all classes that include FeatureTaggable
|
51
|
+
ObjectSpace.each_object(Class).select do |klass|
|
52
|
+
klass < ActiveRecord::Base &&
|
53
|
+
klass.included_modules.include?(PinFlags::FeatureTaggable)
|
54
|
+
end.uniq
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.feature_taggable_options_for_select
|
58
|
+
feature_taggable_models.map { [ it.name, it.name ] }.sort
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.export_as_json
|
62
|
+
all.as_json(only: [ :name, :enabled ]).to_json
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.import_from_json(json, batch_size: 1000)
|
66
|
+
data = JSON.parse(json)
|
67
|
+
|
68
|
+
data.each_slice(batch_size) do |batch|
|
69
|
+
normalized_batch = batch.map do |item|
|
70
|
+
{
|
71
|
+
name: normalize_tag_name(item["name"]),
|
72
|
+
enabled: item["enabled"],
|
73
|
+
created_at: Time.current,
|
74
|
+
updated_at: Time.current
|
75
|
+
}
|
76
|
+
end
|
77
|
+
|
78
|
+
# Use upsert_all for efficient bulk operations
|
79
|
+
upsert_all(
|
80
|
+
normalized_batch,
|
81
|
+
unique_by: :name,
|
82
|
+
update_only: [ :enabled, :updated_at ]
|
83
|
+
)
|
84
|
+
end
|
85
|
+
|
86
|
+
clear_all_cache
|
87
|
+
true
|
88
|
+
rescue JSON::ParserError
|
89
|
+
raise PinFlags::FeatureTagImportError, "Invalid JSON format"
|
90
|
+
rescue StandardError => e
|
91
|
+
raise PinFlags::FeatureTagImportError, "Import failed: #{e.message}"
|
92
|
+
end
|
93
|
+
|
94
|
+
def self.normalize_tag_name(tag_name)
|
95
|
+
tag_name.to_s.strip.downcase.parameterize.underscore
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
class PinFlags::Page
|
2
|
+
DEFAULT_PAGE_SIZE = 10
|
3
|
+
|
4
|
+
attr_reader :records, :index, :page_size
|
5
|
+
|
6
|
+
def initialize(relation, page: 1, page_size: DEFAULT_PAGE_SIZE)
|
7
|
+
@relation = relation
|
8
|
+
@page_size = page_size
|
9
|
+
@index = [ page, 1 ].max
|
10
|
+
end
|
11
|
+
|
12
|
+
def records
|
13
|
+
@relation.limit(page_size).offset(offset)
|
14
|
+
end
|
15
|
+
|
16
|
+
def first?
|
17
|
+
index == 1
|
18
|
+
end
|
19
|
+
|
20
|
+
def last?
|
21
|
+
index == pages_count || empty? || records.empty?
|
22
|
+
end
|
23
|
+
|
24
|
+
def empty?
|
25
|
+
total_count == 0
|
26
|
+
end
|
27
|
+
|
28
|
+
def previous_index
|
29
|
+
[ index - 1, 1 ].max
|
30
|
+
end
|
31
|
+
|
32
|
+
def next_index
|
33
|
+
pages_count&.positive? ? [ index + 1, pages_count ].min : index + 1
|
34
|
+
end
|
35
|
+
|
36
|
+
def pages_count
|
37
|
+
(total_count.to_f / page_size).ceil unless total_count.infinite?
|
38
|
+
end
|
39
|
+
|
40
|
+
def total_count
|
41
|
+
@total_count ||= @relation.size # Uses loaded records if available, otherwise falls back to count
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
def offset
|
46
|
+
(index - 1) * page_size
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
<%# locals: (modal_id:, title:, body_content: nil, footer_buttons: nil) %>
|
2
|
+
|
3
|
+
<div class="modal" id="<%= modal_id %>"
|
4
|
+
x-data="{
|
5
|
+
isOpen: false,
|
6
|
+
open() { this.isOpen = true },
|
7
|
+
close() { this.isOpen = false }
|
8
|
+
}"
|
9
|
+
x-show="isOpen"
|
10
|
+
x-cloak
|
11
|
+
@keydown.escape.window="close()"
|
12
|
+
@open-modal.window="if ($event.detail.modalId === '<%= modal_id %>') open()"
|
13
|
+
:class="{ 'is-active': isOpen }">
|
14
|
+
|
15
|
+
<div class="modal-background" @click="close()"></div>
|
16
|
+
|
17
|
+
<div class="modal-card">
|
18
|
+
<header class="modal-card-head">
|
19
|
+
<p class="modal-card-title"><%= title %></p>
|
20
|
+
<button class="delete" @click="close()"></button>
|
21
|
+
</header>
|
22
|
+
|
23
|
+
<section class="modal-card-body">
|
24
|
+
<% if body_content %>
|
25
|
+
<%= body_content %>
|
26
|
+
<% else %>
|
27
|
+
<%= yield %>
|
28
|
+
<% end %>
|
29
|
+
</section>
|
30
|
+
|
31
|
+
<footer class="modal-card-foot">
|
32
|
+
<% if footer_buttons %>
|
33
|
+
<%= footer_buttons %>
|
34
|
+
<% else %>
|
35
|
+
<button class="button" @click="close()">
|
36
|
+
Close
|
37
|
+
</button>
|
38
|
+
<% end %>
|
39
|
+
</footer>
|
40
|
+
</div>
|
41
|
+
</div>
|
@@ -0,0 +1,34 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title>Pin Flags | <%= content_for(:title) %></title>
|
5
|
+
<%= csrf_meta_tags %>
|
6
|
+
<%= csp_meta_tag %>
|
7
|
+
|
8
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
9
|
+
|
10
|
+
<!-- bulma@1.0.4 -->
|
11
|
+
<%= stylesheet_link_tag "pin_flags/bulma.min", preload: false %>
|
12
|
+
<%= stylesheet_link_tag "pin_flags/application", "data-turbo-track": "reload" %>
|
13
|
+
|
14
|
+
<!-- Use host app's JavaScript (Turbo + Stimulus) -->
|
15
|
+
<%= javascript_importmap_tags %>
|
16
|
+
|
17
|
+
<!-- alpinejs@3.14.8 -->
|
18
|
+
<%= javascript_include_tag "pin_flags/alpine.min", defer: true %>
|
19
|
+
</head>
|
20
|
+
<body>
|
21
|
+
<section class="section">
|
22
|
+
<div class="container">
|
23
|
+
<%= render "layouts/pin_flags/flash" if flash.any? %>
|
24
|
+
<%= yield %>
|
25
|
+
</div>
|
26
|
+
</section>
|
27
|
+
|
28
|
+
<%= render "layouts/pin_flags/modal",
|
29
|
+
modal_id: display_import_form_modal_id,
|
30
|
+
title: "Import Feature Tags" do %>
|
31
|
+
<%= render "pin_flags/feature_tags/imports/form" %>
|
32
|
+
<% end %>
|
33
|
+
</body>
|
34
|
+
</html>
|
@@ -0,0 +1,7 @@
|
|
1
|
+
<%# locals: (filter_param: nil, enabled_param: nil, subscriptions_param: nil) %>
|
2
|
+
|
3
|
+
<div class="has-text-centered mt-6">
|
4
|
+
<p class="has-text-weight-semibold is-size-4">
|
5
|
+
<%= "No feature tags #{(filter_param.present? || enabled_param.present? || subscriptions_param.present?) ? 'found' : 'created yet'}." %>
|
6
|
+
</p>
|
7
|
+
</div>
|
@@ -0,0 +1,82 @@
|
|
1
|
+
<%# locals: (feature_tags:, paginator:, filter_param: nil, enabled_param: nil, subscriptions_param: nil) %>
|
2
|
+
|
3
|
+
<section class="mt-6">
|
4
|
+
<div class="table-container">
|
5
|
+
<table class="table is-striped is-hoverable is-fullwidth">
|
6
|
+
<thead>
|
7
|
+
<tr>
|
8
|
+
<th><abbr title="Feature Tag Name">Name</abbr></th>
|
9
|
+
<th><abbr title="Feature Tag Status">Enabled?</abbr></th>
|
10
|
+
<th><abbr title="Feature Tag Subscriptions">Subscriptions?</abbr></th>
|
11
|
+
<th><abbr title="Available Actions">Actions</abbr></th>
|
12
|
+
</tr>
|
13
|
+
</thead>
|
14
|
+
<tbody>
|
15
|
+
<%= render partial: 'feature_tags_table_row', collection: feature_tags, as: :feature_tag %>
|
16
|
+
</tbody>
|
17
|
+
<tfoot>
|
18
|
+
<tr>
|
19
|
+
<td><abbr title="Feature Tag Name">Name</abbr></td>
|
20
|
+
<td><abbr title="Feature Tag Status">Enabled?</abbr></td>
|
21
|
+
<td><abbr title="Feature Tag Subscriptions">Subscriptions?</abbr></td>
|
22
|
+
<td><abbr title="Available Actions">Actions</abbr></td>
|
23
|
+
</tr>
|
24
|
+
</tfoot>
|
25
|
+
</table>
|
26
|
+
</div>
|
27
|
+
|
28
|
+
<% if paginator.pages_count && paginator.pages_count > 1 %>
|
29
|
+
<nav class="pagination mt-4" role="navigation" aria-label="pagination">
|
30
|
+
<% unless paginator.first? %>
|
31
|
+
<%= link_to "Previous", pin_flags.feature_tags_path(page: paginator.previous_index, filter: filter_param, enabled: enabled_param, subscriptions: subscriptions_param),
|
32
|
+
class: "pagination-previous" %>
|
33
|
+
<% end %>
|
34
|
+
|
35
|
+
<% unless paginator.last? %>
|
36
|
+
<%= link_to "Next page", pin_flags.feature_tags_path(page: paginator.next_index, filter: filter_param, enabled: enabled_param, subscriptions: subscriptions_param),
|
37
|
+
class: "pagination-next" %>
|
38
|
+
<% end %>
|
39
|
+
|
40
|
+
<ul class="pagination-list">
|
41
|
+
<% if paginator.index > 3 %>
|
42
|
+
<li>
|
43
|
+
<%= link_to "1", pin_flags.feature_tags_path(page: 1, filter: filter_param, enabled: enabled_param, subscriptions: subscriptions_param),
|
44
|
+
class: "pagination-link", "aria-label": "Goto page 1" %>
|
45
|
+
</li>
|
46
|
+
<% if paginator.index > 4 %>
|
47
|
+
<li>
|
48
|
+
<span class="pagination-ellipsis">…</span>
|
49
|
+
</li>
|
50
|
+
<% end %>
|
51
|
+
<% end %>
|
52
|
+
|
53
|
+
<% start_page = [paginator.index - 2, 1].max %>
|
54
|
+
<% end_page = [paginator.index + 2, paginator.pages_count].min %>
|
55
|
+
<% (start_page..end_page).each do |page| %>
|
56
|
+
<li>
|
57
|
+
<% if page == paginator.index %>
|
58
|
+
<span class="pagination-link is-current" aria-label="Page <%= page %>" aria-current="page">
|
59
|
+
<%= page %>
|
60
|
+
</span>
|
61
|
+
<% else %>
|
62
|
+
<%= link_to page, pin_flags.feature_tags_path(page: page, filter: filter_param, enabled: enabled_param, subscriptions: subscriptions_param),
|
63
|
+
class: "pagination-link", "aria-label": "Goto page #{page}" %>
|
64
|
+
<% end %>
|
65
|
+
</li>
|
66
|
+
<% end %>
|
67
|
+
|
68
|
+
<% if paginator.index < paginator.pages_count - 2 %>
|
69
|
+
<% if paginator.index < paginator.pages_count - 3 %>
|
70
|
+
<li>
|
71
|
+
<span class="pagination-ellipsis">…</span>
|
72
|
+
</li>
|
73
|
+
<% end %>
|
74
|
+
<li>
|
75
|
+
<%= link_to paginator.pages_count, pin_flags.feature_tags_path(page: paginator.pages_count, filter: filter_param, enabled: enabled_param, subscriptions: subscriptions_param),
|
76
|
+
class: "pagination-link", "aria-label": "Goto page #{paginator.pages_count}" %>
|
77
|
+
</li>
|
78
|
+
<% end %>
|
79
|
+
</ul>
|
80
|
+
</nav>
|
81
|
+
<% end %>
|
82
|
+
</section>
|
@@ -0,0 +1,50 @@
|
|
1
|
+
<%# locals: (feature_tag:) %>
|
2
|
+
|
3
|
+
<tr id="<%= display_feature_tag_table_turbo_frame_row_id(feature_tag) %>"
|
4
|
+
x-data="{
|
5
|
+
removing: false,
|
6
|
+
handleDelete() {
|
7
|
+
if (this.removing) return;
|
8
|
+
if (confirm('<%= j(display_feature_tag_row_delete_confirmation(feature_tag)) %>')) {
|
9
|
+
this.removing = true;
|
10
|
+
setTimeout(() => {
|
11
|
+
this.$el.closest('form').requestSubmit();
|
12
|
+
}, 300);
|
13
|
+
}
|
14
|
+
}
|
15
|
+
}"
|
16
|
+
:class="{ 'pin-flags-fade-out': removing }"
|
17
|
+
class="pin-flags-fade-in">
|
18
|
+
<td>
|
19
|
+
<%= link_to feature_tag, data: { turbo_frame: "_top" } do %>
|
20
|
+
<%= feature_tag.name.truncate(PinFlags::FeatureTag::MAX_NAME_LENGTH / 2) %>
|
21
|
+
<% end %>
|
22
|
+
</td>
|
23
|
+
<td>
|
24
|
+
<%= form_with model: feature_tag, url: pin_flags.feature_tag_path(feature_tag),
|
25
|
+
method: :patch,
|
26
|
+
class: 'form-inline' do |f| %>
|
27
|
+
<div x-data="{ timeout: null }">
|
28
|
+
<%= f.check_box :enabled, class: 'checkbox',
|
29
|
+
"@change": "clearTimeout(timeout); timeout = setTimeout(() => { $el.closest('form').requestSubmit() }, 500)" %>
|
30
|
+
</div>
|
31
|
+
<% end %>
|
32
|
+
</td>
|
33
|
+
<td>
|
34
|
+
<%= link_to feature_tag, data: { turbo_frame: "_top" } do %>
|
35
|
+
<%= feature_tag.feature_subscriptions.any? ? 'Yes' : 'None' %>
|
36
|
+
<% end %>
|
37
|
+
</td>
|
38
|
+
<td>
|
39
|
+
<%= button_to 'Delete', feature_tag, method: :delete,
|
40
|
+
data: {
|
41
|
+
turbo_method: :delete,
|
42
|
+
},
|
43
|
+
aria: {
|
44
|
+
label: "Delete Feature Tag #{feature_tag.name}",
|
45
|
+
describedby: "deletion-help"
|
46
|
+
},
|
47
|
+
class: 'button is-small is-danger is-outlined',
|
48
|
+
"@click.prevent": "handleDelete()" %>
|
49
|
+
</td>
|
50
|
+
</tr>
|
@@ -0,0 +1,7 @@
|
|
1
|
+
<%= turbo_stream.remove(display_feature_tag_table_turbo_frame_row_id(@feature_tag)) %>
|
2
|
+
|
3
|
+
<% unless PinFlags::FeatureTag.exists? %>
|
4
|
+
<%= turbo_stream.update :feature_tags_table do %>
|
5
|
+
<%= render 'empty_feature_tags_table', filter_param: @filter_param %>
|
6
|
+
<% end %>
|
7
|
+
<% end %>
|
@@ -0,0 +1,7 @@
|
|
1
|
+
<%# locals: (filter_param: nil, feature_taggable_type: nil) %>
|
2
|
+
|
3
|
+
<div class="has-text-centered mt-6">
|
4
|
+
<p class="has-text-weight-semibold is-size-4">
|
5
|
+
<%= "No feature subscriptions #{(filter_param.present? || feature_taggable_type.present?) ? 'found' : 'created yet'}." %>
|
6
|
+
</p>
|
7
|
+
</div>
|
data/app/views/pin_flags/feature_tags/feature_subscriptions/_feature_subscriptions_table.html.erb
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
<%# locals: (feature_tag:, paginator:, feature_subscriptions:, filter_param: nil, feature_taggable_type: nil) %>
|
2
|
+
|
3
|
+
<section class="mt-6">
|
4
|
+
<div class="table-container">
|
5
|
+
<table class="table is-striped is-hoverable is-fullwidth">
|
6
|
+
<thead>
|
7
|
+
<tr>
|
8
|
+
<th><abbr title="Feature Taggable Type">Taggable Type</abbr></th>
|
9
|
+
<th><abbr title="Feature Taggable ID">Taggable ID</abbr></th>
|
10
|
+
<th><abbr title="Available Actions">Actions</abbr></th>
|
11
|
+
</tr>
|
12
|
+
</thead>
|
13
|
+
<tbody>
|
14
|
+
<%= render partial: 'pin_flags/feature_tags/feature_subscriptions/feature_subscriptions_table_row',
|
15
|
+
collection: feature_subscriptions,
|
16
|
+
as: :feature_subscription,
|
17
|
+
locals: { feature_tag: feature_tag } %>
|
18
|
+
</tbody>
|
19
|
+
<tfoot>
|
20
|
+
<tr>
|
21
|
+
<td><abbr title="Feature Taggable Type">Taggable Type</abbr></td>
|
22
|
+
<td><abbr title="Feature Taggable ID">Taggable ID</abbr></td>
|
23
|
+
<td><abbr title="Available Actions">Actions</abbr></td>
|
24
|
+
</tr>
|
25
|
+
</tfoot>
|
26
|
+
</table>
|
27
|
+
</div>
|
28
|
+
|
29
|
+
<% if paginator.pages_count && paginator.pages_count > 1 %>
|
30
|
+
<nav class="pagination mt-4" role="navigation" aria-label="pagination">
|
31
|
+
<% unless paginator.first? %>
|
32
|
+
<%= link_to "Previous", pin_flags.feature_tag_path(feature_tag, page: paginator.previous_index, filter: filter_param, feature_taggable_type: feature_taggable_type),
|
33
|
+
class: "pagination-previous" %>
|
34
|
+
<% end %>
|
35
|
+
|
36
|
+
<% unless paginator.last? %>
|
37
|
+
<%= link_to "Next page", pin_flags.feature_tag_path(feature_tag, page: paginator.next_index, filter: filter_param, feature_taggable_type: feature_taggable_type),
|
38
|
+
class: "pagination-next" %>
|
39
|
+
<% end %>
|
40
|
+
|
41
|
+
<ul class="pagination-list">
|
42
|
+
<% if paginator.index > 3 %>
|
43
|
+
<li>
|
44
|
+
<%= link_to "1", pin_flags.feature_tag_path(feature_tag, page: 1, filter: filter_param, feature_taggable_type: feature_taggable_type),
|
45
|
+
class: "pagination-link", "aria-label": "Goto page 1" %>
|
46
|
+
</li>
|
47
|
+
<% if paginator.index > 4 %>
|
48
|
+
<li>
|
49
|
+
<span class="pagination-ellipsis">…</span>
|
50
|
+
</li>
|
51
|
+
<% end %>
|
52
|
+
<% end %>
|
53
|
+
|
54
|
+
<% start_page = [paginator.index - 2, 1].max %>
|
55
|
+
<% end_page = [paginator.index + 2, paginator.pages_count].min %>
|
56
|
+
<% (start_page..end_page).each do |page| %>
|
57
|
+
<li>
|
58
|
+
<% if page == paginator.index %>
|
59
|
+
<span class="pagination-link is-current" aria-label="Page <%= page %>" aria-current="page">
|
60
|
+
<%= page %>
|
61
|
+
</span>
|
62
|
+
<% else %>
|
63
|
+
<%= link_to page, pin_flags.feature_tag_path(feature_tag, page: page, filter: filter_param, feature_taggable_type: feature_taggable_type),
|
64
|
+
class: "pagination-link", "aria-label": "Goto page #{page}" %>
|
65
|
+
<% end %>
|
66
|
+
</li>
|
67
|
+
<% end %>
|
68
|
+
|
69
|
+
<% if paginator.index < paginator.pages_count - 2 %>
|
70
|
+
<% if paginator.index < paginator.pages_count - 3 %>
|
71
|
+
<li>
|
72
|
+
<span class="pagination-ellipsis">…</span>
|
73
|
+
</li>
|
74
|
+
<% end %>
|
75
|
+
<li>
|
76
|
+
<%= link_to paginator.pages_count, pin_flags.feature_tag_path(feature_tag, page: paginator.pages_count, filter: filter_param, feature_taggable_type: feature_taggable_type),
|
77
|
+
class: "pagination-link", "aria-label": "Goto page #{paginator.pages_count}" %>
|
78
|
+
</li>
|
79
|
+
<% end %>
|
80
|
+
</ul>
|
81
|
+
</nav>
|
82
|
+
<% end %>
|
83
|
+
</section>
|
@@ -0,0 +1,37 @@
|
|
1
|
+
<%# locals: (feature_tag:, feature_subscription:) %>
|
2
|
+
|
3
|
+
<tr id="<%= display_feature_subscription_table_turbo_frame_row_id(feature_subscription) %>"
|
4
|
+
x-data="{
|
5
|
+
removing: false,
|
6
|
+
handleDelete() {
|
7
|
+
if (this.removing) return;
|
8
|
+
if (confirm('<%= j(display_feature_subscription_row_delete_confirmation(feature_subscription)) %>')) {
|
9
|
+
this.removing = true;
|
10
|
+
setTimeout(() => {
|
11
|
+
this.$el.closest('form').requestSubmit();
|
12
|
+
}, 300);
|
13
|
+
}
|
14
|
+
}
|
15
|
+
}"
|
16
|
+
:class="{ 'pin-flags-fade-out': removing }"
|
17
|
+
class="pin-flags-fade-in">
|
18
|
+
<td>
|
19
|
+
<%= feature_subscription.feature_taggable_type %>
|
20
|
+
</td>
|
21
|
+
<td>
|
22
|
+
<%= feature_subscription.feature_taggable_id %>
|
23
|
+
</td>
|
24
|
+
<td>
|
25
|
+
<%= button_to 'Unsubscribe', pin_flags.feature_tag_feature_subscription_path(feature_tag, feature_subscription),
|
26
|
+
method: :delete,
|
27
|
+
data: {
|
28
|
+
turbo_method: :delete,
|
29
|
+
},
|
30
|
+
aria: {
|
31
|
+
label: display_feature_subscription_delete_button_aria_label(feature_subscription),
|
32
|
+
describedby: "deletion-help"
|
33
|
+
},
|
34
|
+
class: "button is-small is-danger is-outlined",
|
35
|
+
"@click.prevent": "handleDelete()" %>
|
36
|
+
</td>
|
37
|
+
</tr>
|
@@ -0,0 +1,10 @@
|
|
1
|
+
<%= turbo_stream.update :new_feature_subscription do %>
|
2
|
+
<%= render 'pin_flags/feature_tags/new_feature_subscription_link', feature_tag: @feature_tag %>
|
3
|
+
<% end %>
|
4
|
+
|
5
|
+
<%= turbo_stream.update :feature_subscriptions_table do %>
|
6
|
+
<%= render 'pin_flags/feature_tags/feature_subscriptions/feature_subscriptions_table',
|
7
|
+
feature_tag: @feature_tag, feature_subscriptions: @feature_subscriptions, paginator: @paginator,
|
8
|
+
filter_param: @filter_param, feature_taggable_type: @feature_taggable_type
|
9
|
+
%>
|
10
|
+
<% end %>
|
@@ -0,0 +1,7 @@
|
|
1
|
+
<%= turbo_stream.remove(display_feature_subscription_table_turbo_frame_row_id(@feature_subscription)) %>
|
2
|
+
|
3
|
+
<% if @feature_tag.feature_subscriptions.empty? %>
|
4
|
+
<%= turbo_stream.update :feature_subscriptions_table do %>
|
5
|
+
<%= render 'pin_flags/feature_tags/feature_subscriptions/empty_feature_subscriptions_table' %>
|
6
|
+
<% end %>
|
7
|
+
<% end %>
|
@@ -0,0 +1,83 @@
|
|
1
|
+
<%= turbo_frame_tag :new_feature_subscription do %>
|
2
|
+
<div class="container">
|
3
|
+
<%= form_with model: [@feature_tag, @feature_subscription], url: feature_tag_feature_subscriptions_path(@feature_tag) do |form| %>
|
4
|
+
<%= render "pin_flags/shared/error_messages", resource: @feature_subscription %>
|
5
|
+
|
6
|
+
<div class="columns">
|
7
|
+
<div class="column is-half">
|
8
|
+
<div class="field mb-5">
|
9
|
+
<div class="is-flex is-align-items-flex-start">
|
10
|
+
<%= form.label :feature_taggable_type, 'Feature Taggable Type', class: "label" %>
|
11
|
+
<span class="has-text-danger ml-1">*</span>
|
12
|
+
</div>
|
13
|
+
<p class="help is-info mb-2">
|
14
|
+
Must be a valid <strong>"feature_taggable_type"</strong> based on your database schema.
|
15
|
+
</p>
|
16
|
+
<div class="control">
|
17
|
+
<div class="select is-fullwidth">
|
18
|
+
<%= form.select :feature_taggable_type,
|
19
|
+
PinFlags::FeatureTag.feature_taggable_options_for_select,
|
20
|
+
{ prompt: 'Select Taggable Type' },
|
21
|
+
required: true
|
22
|
+
%>
|
23
|
+
</div>
|
24
|
+
</div>
|
25
|
+
</div>
|
26
|
+
|
27
|
+
<section x-data="{ bulkUpload: false }">
|
28
|
+
<div class="field mb-4">
|
29
|
+
<%= form.label :bulk_upload, 'Bulk Upload?', class: 'label' %>
|
30
|
+
<div class="control">
|
31
|
+
<%= form.check_box :bulk_upload, class: 'checkbox',
|
32
|
+
"@change": "bulkUpload = $el.checked"
|
33
|
+
%>
|
34
|
+
</div>
|
35
|
+
</div>
|
36
|
+
|
37
|
+
<div class="field mb-4">
|
38
|
+
<div class="is-flex is-align-items-flex-start">
|
39
|
+
<%= form.label :feature_taggable_id, 'Feature Taggable ID', class: "label" %>
|
40
|
+
<span class="has-text-danger ml-1">*</span>
|
41
|
+
</div>
|
42
|
+
<p class="help is-info mb-2">
|
43
|
+
Must be a valid <strong>"id"</strong> column value for the feature taggable type in the database.
|
44
|
+
</p>
|
45
|
+
<div class="control" x-html="bulkUpload ? $refs.textareaTemplate.innerHTML : $refs.textTemplate.innerHTML">
|
46
|
+
<!-- This div will be populated by Alpine -->
|
47
|
+
</div>
|
48
|
+
</div>
|
49
|
+
|
50
|
+
<!-- Templates -->
|
51
|
+
<template x-ref="textTemplate">
|
52
|
+
<div>
|
53
|
+
<%= form.text_field :feature_taggable_id, class: "input",
|
54
|
+
required: true, autofocus: false, autocomplete: "off",
|
55
|
+
placeholder: "Enter a single Feature Taggable ID."
|
56
|
+
%>
|
57
|
+
</div>
|
58
|
+
</template>
|
59
|
+
|
60
|
+
<template x-ref="textareaTemplate">
|
61
|
+
<div>
|
62
|
+
<%= form.text_area :feature_taggable_id, class: "textarea",
|
63
|
+
required: true, autofocus: false, autocomplete: "off",
|
64
|
+
rows: 6,
|
65
|
+
placeholder: "Enter comma separated values for Feature Taggable IDs. All IDs must be for the same Feature Taggable Type."
|
66
|
+
%>
|
67
|
+
</div>
|
68
|
+
</template>
|
69
|
+
</section>
|
70
|
+
|
71
|
+
<div class="field is-grouped my-4">
|
72
|
+
<div class="control">
|
73
|
+
<%= form.submit "Create Feature Subscription", class: "button is-success is-outlined" %>
|
74
|
+
</div>
|
75
|
+
<div class="control">
|
76
|
+
<%= link_to "Cancel", @feature_tag, class: "button is-danger is-outlined" %>
|
77
|
+
</div>
|
78
|
+
</div>
|
79
|
+
</div>
|
80
|
+
</div>
|
81
|
+
<% end %>
|
82
|
+
</div>
|
83
|
+
<% end %>
|