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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +244 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/javascripts/pin_flags/alpine.min.js +5 -0
  6. data/app/assets/stylesheets/pin_flags/application.css +25 -0
  7. data/app/assets/stylesheets/pin_flags/bulma.min.css +21564 -0
  8. data/app/assets/stylesheets/pin_flags/forms.css +8 -0
  9. data/app/controllers/pin_flags/application_controller.rb +6 -0
  10. data/app/controllers/pin_flags/feature_tags/exports_controller.rb +18 -0
  11. data/app/controllers/pin_flags/feature_tags/feature_subscriptions_controller.rb +138 -0
  12. data/app/controllers/pin_flags/feature_tags/imports_controller.rb +65 -0
  13. data/app/controllers/pin_flags/feature_tags_controller.rb +141 -0
  14. data/app/helpers/pin_flags/application_helper.rb +20 -0
  15. data/app/helpers/pin_flags/feature_tags/feature_subscriptions_helper.rb +23 -0
  16. data/app/helpers/pin_flags/feature_tags/imports_helper.rb +7 -0
  17. data/app/helpers/pin_flags/feature_tags_helper.rb +12 -0
  18. data/app/jobs/pin_flags/application_job.rb +4 -0
  19. data/app/mailers/pin_flags/application_mailer.rb +6 -0
  20. data/app/models/concerns/pin_flags/feature_taggable.rb +39 -0
  21. data/app/models/pin_flags/application_record.rb +5 -0
  22. data/app/models/pin_flags/feature_subscription/bulk_processor.rb +80 -0
  23. data/app/models/pin_flags/feature_subscription.rb +21 -0
  24. data/app/models/pin_flags/feature_tag/cacheable.rb +44 -0
  25. data/app/models/pin_flags/feature_tag.rb +98 -0
  26. data/app/models/pin_flags/page.rb +48 -0
  27. data/app/views/layouts/pin_flags/_flash.html.erb +6 -0
  28. data/app/views/layouts/pin_flags/_modal.html.erb +41 -0
  29. data/app/views/layouts/pin_flags/application.html.erb +34 -0
  30. data/app/views/pin_flags/feature_tags/_empty_feature_tags_table.html.erb +7 -0
  31. data/app/views/pin_flags/feature_tags/_feature_tags_table.html.erb +82 -0
  32. data/app/views/pin_flags/feature_tags/_feature_tags_table_row.html.erb +50 -0
  33. data/app/views/pin_flags/feature_tags/_new_feature_subscription_link.html.erb +8 -0
  34. data/app/views/pin_flags/feature_tags/_new_feature_tag_link.html.erb +6 -0
  35. data/app/views/pin_flags/feature_tags/create.turbo_stream.erb +7 -0
  36. data/app/views/pin_flags/feature_tags/destroy.turbo_stream.erb +7 -0
  37. data/app/views/pin_flags/feature_tags/feature_subscriptions/_empty_feature_subscriptions_table.html.erb +7 -0
  38. data/app/views/pin_flags/feature_tags/feature_subscriptions/_feature_subscriptions_table.html.erb +83 -0
  39. data/app/views/pin_flags/feature_tags/feature_subscriptions/_feature_subscriptions_table_row.html.erb +37 -0
  40. data/app/views/pin_flags/feature_tags/feature_subscriptions/create.turbo_stream.erb +10 -0
  41. data/app/views/pin_flags/feature_tags/feature_subscriptions/destroy.turbo_stream.erb +7 -0
  42. data/app/views/pin_flags/feature_tags/feature_subscriptions/new.html.erb +83 -0
  43. data/app/views/pin_flags/feature_tags/imports/_form.html.erb +66 -0
  44. data/app/views/pin_flags/feature_tags/index.html.erb +123 -0
  45. data/app/views/pin_flags/feature_tags/new.html.erb +34 -0
  46. data/app/views/pin_flags/feature_tags/show.html.erb +89 -0
  47. data/app/views/pin_flags/feature_tags/update.html.erb +2 -0
  48. data/app/views/pin_flags/feature_tags/update.turbo_stream.erb +3 -0
  49. data/app/views/pin_flags/shared/_error_messages.html.erb +11 -0
  50. data/config/routes.rb +12 -0
  51. data/db/migrate/20250626042529_create_feature_tag.rb +12 -0
  52. data/db/migrate/20250626050146_create_feature_subscriptions.rb +15 -0
  53. data/lib/generators/pin_flags/install/install_generator.rb +21 -0
  54. data/lib/generators/pin_flags/install/templates/create_feature_subscriptions.rb +14 -0
  55. data/lib/generators/pin_flags/install/templates/create_feature_tag.rb +12 -0
  56. data/lib/pin_flags/engine.rb +13 -0
  57. data/lib/pin_flags/version.rb +3 -0
  58. data/lib/pin_flags.rb +13 -0
  59. data/lib/tasks/pin_flags_tasks.rake +4 -0
  60. 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,6 @@
1
+ <% flash.each do |type, message| %>
2
+ <div class="notification <%= flash_class(type) %> is-light">
3
+ <button class="delete" onclick="this.parentElement.remove()"></button>
4
+ <%= message %>
5
+ </div>
6
+ <% 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">&hellip;</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">&hellip;</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,8 @@
1
+ <%# locals: (feature_tag:) %>
2
+
3
+ <div>
4
+ <%= link_to "New Subscription",
5
+ pin_flags.new_feature_tag_feature_subscription_path(feature_tag_id: feature_tag.id),
6
+ class: "button is-success is-outlined"
7
+ %>
8
+ </div>
@@ -0,0 +1,6 @@
1
+ <%# locals: () %>
2
+
3
+ <div>
4
+ <%= link_to "Add New Feature Tag", pin_flags.new_feature_tag_path,
5
+ class: "button is-success is-outlined" %>
6
+ </div>
@@ -0,0 +1,7 @@
1
+ <%= turbo_stream.update :new_feature_tag do %>
2
+ <%= render 'new_feature_tag_link' %>
3
+ <% end %>
4
+
5
+ <%= turbo_stream.update :feature_tags_table do %>
6
+ <%= render 'feature_tags_table', feature_tags: @feature_tags, paginator: @paginator %>
7
+ <% end %>
@@ -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>
@@ -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">&hellip;</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">&hellip;</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 %>