active_storage-async_variants-ui 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 (35) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +24 -0
  3. data/.ruby-gemset +1 -0
  4. data/.ruby-version +1 -0
  5. data/Appraisals +11 -0
  6. data/CHANGELOG.md +8 -0
  7. data/CLAUDE.md +35 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +89 -0
  10. data/Rakefile +27 -0
  11. data/app/assets/javascripts/progress-bar.js +347 -0
  12. data/app/assets/javascripts/retry.js +11 -0
  13. data/app/assets/stylesheets/progress.css +35 -0
  14. data/app/assets/stylesheets/retry.css +98 -0
  15. data/app/controllers/active_storage/async_variants/states_controller.rb +71 -0
  16. data/app/views/active_storage/async_variants/states/_failed.html.erb +44 -0
  17. data/app/views/active_storage/async_variants/states/_pending.html.erb +1 -0
  18. data/app/views/active_storage/async_variants/states/_processed.html.erb +5 -0
  19. data/app/views/active_storage/async_variants/states/_processing.html.erb +15 -0
  20. data/app/views/active_storage/async_variants/states/show.html.erb +10 -0
  21. data/config/routes.rb +20 -0
  22. data/features/retry_flow.feature +20 -0
  23. data/features/retry_visibility.feature +23 -0
  24. data/features/state_rendering.feature +40 -0
  25. data/features/step_definitions/dummy_steps.rb +144 -0
  26. data/features/support/env.rb +28 -0
  27. data/gemfiles/rails_7.2.gemfile +18 -0
  28. data/gemfiles/rails_8.0.gemfile +18 -0
  29. data/gemfiles/rails_8.1.gemfile +18 -0
  30. data/lib/active_storage/async_variants/asset_tag_helper_extension.rb +59 -0
  31. data/lib/active_storage/async_variants/helper.rb +80 -0
  32. data/lib/active_storage/async_variants/ui/version.rb +9 -0
  33. data/lib/active_storage/async_variants/ui.rb +45 -0
  34. data/log/.gitkeep +0 -0
  35. metadata +118 -0
@@ -0,0 +1,98 @@
1
+ /* Scoped to the retry dialog's shadow root (linked inside its <template>). */
2
+ :host { display: contents; }
3
+
4
+ button { font: inherit; cursor: pointer; }
5
+
6
+ .opener {
7
+ position: absolute;
8
+ top: 6px; right: 6px;
9
+ width: 22px; height: 22px;
10
+ background: #2b7cd6;
11
+ color: #fff;
12
+ border: 2px solid #fff;
13
+ border-radius: 50%;
14
+ font-size: 13px;
15
+ font-weight: bold;
16
+ line-height: 1;
17
+ padding: 0;
18
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
19
+ display: var(--retry-opener-display, none);
20
+ align-items: center;
21
+ justify-content: center;
22
+ }
23
+ .opener:hover { background: #1a5fb0; }
24
+
25
+ dialog {
26
+ width: 720px;
27
+ max-width: 90vw;
28
+ max-height: 80vh;
29
+ padding: 0;
30
+ border: 1px solid #999;
31
+ border-radius: 6px;
32
+ background: #fff;
33
+ color: #222;
34
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
35
+ font: 13px/1.4 system-ui, -apple-system, sans-serif;
36
+ }
37
+ dialog[open] { display: flex; flex-direction: column; }
38
+ dialog::backdrop { background: rgba(0, 0, 0, 0.5); }
39
+
40
+ dialog > button {
41
+ position: absolute;
42
+ top: 10px;
43
+ right: 12px;
44
+ width: 32px; height: 32px;
45
+ background: transparent;
46
+ border: none;
47
+ color: #888;
48
+ font-size: 24px;
49
+ line-height: 1;
50
+ padding: 0;
51
+ border-radius: 4px;
52
+ z-index: 1;
53
+ }
54
+ dialog > button:hover { color: #000; background: rgba(0, 0, 0, 0.08); }
55
+
56
+ header { padding: 20px 24px 0 24px; }
57
+ header h3 {
58
+ margin: 0;
59
+ font-size: 16px;
60
+ font-weight: bold;
61
+ color: #111;
62
+ padding-right: 32px;
63
+ }
64
+
65
+ section {
66
+ flex: 1 1 auto;
67
+ overflow: auto;
68
+ padding: 12px 24px 20px 24px;
69
+ min-height: 0;
70
+ }
71
+ section pre {
72
+ background: #f5f5f5;
73
+ color: #222;
74
+ border: 1px solid #ddd;
75
+ padding: 8px;
76
+ font: 11px/1.4 ui-monospace, Menlo, monospace;
77
+ white-space: pre-wrap;
78
+ word-break: break-all;
79
+ margin: 0;
80
+ }
81
+
82
+ footer {
83
+ flex: 0 0 auto;
84
+ border-top: 1px solid #ddd;
85
+ padding: 12px 20px;
86
+ display: flex;
87
+ justify-content: flex-end;
88
+ background: #fafafa;
89
+ }
90
+ footer button {
91
+ padding: 8px 16px;
92
+ background: #2a7;
93
+ color: #fff;
94
+ border: none;
95
+ border-radius: 4px;
96
+ font-weight: bold;
97
+ }
98
+ footer button:disabled { opacity: 0.5; cursor: wait; }
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module AsyncVariants
5
+ class StatesController < ActiveStorage::AsyncVariants.parent_controller.constantize
6
+ # signed_blob_id + variation_key in URL act as CSRF: both require app's secret.
7
+ skip_forgery_protection
8
+
9
+ helper "turbo/frames"
10
+ helper ActiveStorage::AsyncVariants::Helper
11
+
12
+ layout false
13
+
14
+ # 1x1 transparent GIF served at a filename-bearing URL, so the
15
+ # processing/failed <img> identifies its media without fetching anything.
16
+ TRANSPARENT_GIF = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7".unpack1("m").freeze
17
+
18
+ before_action :set_variant, only: %i[show retry]
19
+
20
+ def show
21
+ end
22
+
23
+ def placeholder
24
+ expires_in 1.year, public: true
25
+ send_data TRANSPARENT_GIF, type: "image/gif", disposition: "inline"
26
+ end
27
+
28
+ def retry
29
+ @variant.blob.variant_records.where(
30
+ variation_digest: @variant.variation.digest,
31
+ state: "failed",
32
+ ).destroy_all
33
+ @variant.enqueue!
34
+
35
+ redirect_to Rails.application.routes.url_helpers.async_variant_state_path(
36
+ signed_blob_id: params[:signed_blob_id],
37
+ variation_key: params[:variation_key],
38
+ kind: params[:kind],
39
+ direct: params[:direct],
40
+ opts: async_variant_html_options.presence,
41
+ ), status: :see_other
42
+ end
43
+
44
+ helper_method :async_variant_kind, :async_variant_direct?, :async_variant_html_options
45
+
46
+ private
47
+
48
+ def set_variant
49
+ blob = ActiveStorage::Blob.find_signed!(params[:signed_blob_id])
50
+ variation = ActiveStorage::Variation.decode(params[:variation_key])
51
+ @variant = blob.variant(variation)
52
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
53
+ head :not_found
54
+ end
55
+
56
+ def async_variant_kind
57
+ params[:kind].to_s == "video" ? :video : :image
58
+ end
59
+
60
+ def async_variant_direct?
61
+ ActiveModel::Type::Boolean.new.cast(params[:direct])
62
+ end
63
+
64
+ def async_variant_html_options
65
+ params[:opts]
66
+ &.permit(*ActiveStorage::AsyncVariants::PASS_THROUGH_HTML_OPTIONS)
67
+ .to_h
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,44 @@
1
+ <div class="async-variant-state async-variant-failed">
2
+ <%= async_variant_placeholder_tag(variant, html_options, kind: kind) %>
3
+
4
+ <%= content_tag "progress-bar", "", mode: "circular", error: "", class: "async-variant-progress" %>
5
+
6
+ <%= ActiveStorage::AsyncVariants.stylesheet_link_tag "progress" %>
7
+ <%= ActiveStorage::AsyncVariants.javascript_include_tag "progress-bar", type: "module" %>
8
+
9
+ <% if ActiveStorage::AsyncVariants.retry_visible?(self) %>
10
+ <% dialog_id = "#{async_variant_frame_id(variant)}-error" %>
11
+ <% form_id = "#{dialog_id}-form" %>
12
+ <%# Light DOM, so the custom property inherits into the shadow root. %>
13
+ <style>
14
+ .async-variant-failed { --retry-opener-display: none; }
15
+ .async-variant-failed:hover { --retry-opener-display: inline-flex; }
16
+ </style>
17
+ <%# In the light DOM so its submit reaches Turbo, and so preventDefault on the
18
+ buttons doesn't also cancel the submit. %>
19
+ <form action="<%= Rails.application.routes.url_helpers.async_variant_state_retry_path(signed_blob_id:, variation_key:, kind:, direct:, opts: html_options) %>"
20
+ method="post"
21
+ id="<%= form_id %>"
22
+ data-turbo-frame="<%= async_variant_frame_id(variant) %>"
23
+ hidden></form>
24
+ <async-variant-retry>
25
+ <template shadowrootmode="open">
26
+ <%= ActiveStorage::AsyncVariants.stylesheet_link_tag "retry" %>
27
+ <button class="opener" type="button"
28
+ onclick="event.preventDefault(); event.stopPropagation(); this.getRootNode().getElementById('<%= dialog_id %>').showModal()"
29
+ title="Variant processing failed — click for details">?</button>
30
+ <dialog id="<%= dialog_id %>">
31
+ <button type="button"
32
+ onclick="event.preventDefault(); event.stopPropagation(); this.closest('dialog').close()">&times;</button>
33
+ <header><h3>Variant processing failed</h3></header>
34
+ <section><pre><%= variant.error %></pre></section>
35
+ <footer>
36
+ <button type="button"
37
+ onclick="event.preventDefault(); event.stopPropagation(); this.disabled = true; this.textContent = 'Retrying…'; document.getElementById('<%= form_id %>').requestSubmit()">Retry processing</button>
38
+ </footer>
39
+ </dialog>
40
+ </template>
41
+ </async-variant-retry>
42
+ <%= ActiveStorage::AsyncVariants.javascript_include_tag "retry", type: "module" %>
43
+ <% end %>
44
+ </div>
@@ -0,0 +1 @@
1
+ <%= render "active_storage/async_variants/states/processing", local_assigns %>
@@ -0,0 +1,5 @@
1
+ <%
2
+ method = kind == :video ? :video_tag : :image_tag
3
+ src = direct ? async_variant_direct_url(variant) : async_variant_representation_path(variant)
4
+ %>
5
+ <%= send method, src, **html_options.symbolize_keys %>
@@ -0,0 +1,15 @@
1
+ <div class="async-variant-state async-variant-processing">
2
+ <%= async_variant_placeholder_tag(variant, html_options, kind: kind) %>
3
+
4
+ <%= content_tag "progress-bar", "", mode: "circular", class: "async-variant-progress", percent: variant.progress, rate: variant.progress_rate %>
5
+ </div>
6
+
7
+ <%= ActiveStorage::AsyncVariants.stylesheet_link_tag "progress" %>
8
+ <%= ActiveStorage::AsyncVariants.javascript_include_tag "progress-bar", type: "module" %>
9
+ <% interval_ms = ActiveStorage::AsyncVariants.heartbeat_interval.in_seconds * 1000 %>
10
+ <script>
11
+ (() => {
12
+ const frame = document.currentScript?.closest("turbo-frame")
13
+ setTimeout(() => frame?.reload(), <%= interval_ms %>)
14
+ })()
15
+ </script>
@@ -0,0 +1,10 @@
1
+ <%= turbo_frame_tag async_variant_frame_id(@variant) do %>
2
+ <%= render "active_storage/async_variants/states/#{@variant.async_state}", {
3
+ variant: @variant,
4
+ html_options: async_variant_html_options,
5
+ kind: async_variant_kind,
6
+ direct: async_variant_direct?,
7
+ signed_blob_id: params[:signed_blob_id],
8
+ variation_key: params[:variation_key],
9
+ } %>
10
+ <% end %>
data/config/routes.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.routes.draw do
4
+ get "/active_storage/async_variants/states/:signed_blob_id/:variation_key",
5
+ to: "active_storage/async_variants/states#show",
6
+ as: :async_variant_state
7
+ post "/active_storage/async_variants/states/:signed_blob_id/:variation_key/retry",
8
+ to: "active_storage/async_variants/states#retry",
9
+ as: :async_variant_state_retry
10
+
11
+ # Tiny 1x1 GIF whose URL carries the media's filename, so the processing/failed
12
+ # box reserves layout without fetching the original through a representation.
13
+ get "/active_storage/async_variants/placeholder/:filename",
14
+ to: "active_storage/async_variants/states#placeholder",
15
+ as: :async_variant_placeholder,
16
+ constraints: { filename: %r{[^/]+} },
17
+ format: false
18
+
19
+ ActiveStorage::AsyncVariants::Assets.draw(self, "/active_storage/async_variants/assets")
20
+ end
@@ -0,0 +1,20 @@
1
+ Feature: Clicking the retry affordance opens a dialog and resubmits the variant
2
+
3
+ Background:
4
+ Given a user with an attached avatar
5
+ And the retry affordance is visible to everyone
6
+
7
+ Scenario: Clicking the opener reveals the error inside the shadow-DOM dialog
8
+ Given the avatar's :thumb_proc variant is in failed state with error "boom from upstream"
9
+ When I visit the avatar page for the :thumb_proc variant
10
+ And I click the retry opener
11
+ Then the failure dialog should be open
12
+ And the failure dialog should contain "boom from upstream"
13
+
14
+ Scenario: Submitting Retry destroys the failed record and re-enqueues
15
+ Given the avatar's :thumb_proc variant is in failed state with error "boom"
16
+ When I visit the avatar page for the :thumb_proc variant
17
+ And I click the retry opener
18
+ And I click "Retry processing"
19
+ Then the frame should render the processing state
20
+ And the page should not have navigated away
@@ -0,0 +1,23 @@
1
+ Feature: The retry affordance is gated by ActiveStorage::AsyncVariants.retry_visible_if
2
+
3
+ Scenario: When retry_visible_if returns true, the retry chrome renders
4
+ Given a user with an attached avatar
5
+ And the retry affordance is visible to everyone
6
+ And the avatar's :thumb_proc variant is in failed state with error "boom"
7
+ When I visit the avatar page for the :thumb_proc variant
8
+ Then the retry affordance should be visible
9
+
10
+ Scenario: When retry_visible_if returns false, the retry chrome is omitted
11
+ Given a user with an attached avatar
12
+ And the retry affordance is hidden
13
+ And the avatar's :thumb_proc variant is in failed state with error "boom"
14
+ When I visit the avatar page for the :thumb_proc variant
15
+ Then the retry affordance should NOT be visible
16
+
17
+ Scenario: A proc that consults current_user gates the affordance per-viewer
18
+ Given a user with an attached avatar
19
+ And the retry affordance requires a signed-in user
20
+ And the avatar's :thumb_proc variant is in failed state with error "boom"
21
+ When I sign in as the user
22
+ And I visit the avatar page for the :thumb_proc variant
23
+ Then the retry affordance should be visible
@@ -0,0 +1,40 @@
1
+ Feature: image_tag with async: true emits the right markup per variant state
2
+
3
+ Scenario: A processed variant emits a plain <img> -- no turbo-frame, no chrome
4
+ Given a user with an attached avatar
5
+ And the avatar's :thumb_proc variant is in processed state
6
+ When I visit the avatar page for the :thumb_proc variant
7
+ Then the page should NOT contain a turbo-frame
8
+
9
+ Scenario: An unprocessed variant emits a turbo-frame in processing state
10
+ Given a user with an attached avatar
11
+ And the avatar's :thumb_proc variant is in processing state
12
+ When I visit the avatar page for the :thumb_proc variant
13
+ Then the page should contain a turbo-frame
14
+ And the frame should render the processing state
15
+
16
+ Scenario: A processing variant renders an indeterminate progress bar before any heartbeat
17
+ Given a user with an attached avatar
18
+ And the avatar's :thumb_proc variant is in processing state
19
+ When I visit the avatar page for the :thumb_proc variant
20
+ Then the page should contain a turbo-frame
21
+ And the frame should render a progress bar
22
+ And the progress bar should be indeterminate
23
+ And the placeholder image should reserve layout
24
+
25
+ Scenario: A processing variant with reported progress renders a determinate progress bar
26
+ Given a user with an attached avatar
27
+ And the avatar's :thumb_proc variant is in processing state
28
+ And the avatar's :thumb_proc variant has reported 42% progress
29
+ When I visit the avatar page for the :thumb_proc variant
30
+ Then the page should contain a turbo-frame
31
+ And the progress bar should show 42%
32
+
33
+ Scenario: A failed variant renders the failed partial inside the frame
34
+ Given a user with an attached avatar
35
+ And the retry affordance is visible to everyone
36
+ And the avatar's :thumb_proc variant is in failed state with error "boom"
37
+ When I visit the avatar page for the :thumb_proc variant
38
+ Then the page should contain a turbo-frame
39
+ And the frame should render the failed state
40
+ And the frame should render an error progress bar
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ Given /^a user with an attached avatar$/ do
4
+ @user = User.create!
5
+ attach_avatar_to(@user)
6
+ end
7
+
8
+ Given /^the avatar's :(\w+) variant is in (pending|processing|processed|failed) state(?: with error "([^"]+)")?$/ do |variant_name, state, error|
9
+ variant = @user.avatar.variant(variant_name.to_sym)
10
+ if state == "processed"
11
+ simulate_processed_variant(variant)
12
+ else
13
+ create_variant_record(variant, state: state, error: error)
14
+ end
15
+ end
16
+
17
+ Given /^the avatar's :(\w+) variant has reported (\d+)% progress$/ do |variant_name, percent|
18
+ variant = @user.avatar.variant(variant_name.to_sym)
19
+ record = variant.blob.variant_records.find_by(variation_digest: variant.variation.digest)
20
+ record.update!(progress: percent.to_i)
21
+ end
22
+
23
+ Then /^the frame should render a progress bar$/ do
24
+ expect(page).to have_css("turbo-frame progress-bar", visible: :all)
25
+ end
26
+
27
+ Then /^the frame should render an error progress bar$/ do
28
+ expect(page).to have_css("turbo-frame progress-bar[error]", visible: :all)
29
+ end
30
+
31
+ Then /^the progress bar should be indeterminate$/ do
32
+ expect(page).to have_css("turbo-frame progress-bar:not([percent])", visible: :all)
33
+ end
34
+
35
+ Then /^the progress bar should show (\d+)%$/ do |percent|
36
+ expect(page).to have_css(%(turbo-frame progress-bar[percent="#{percent}"]), visible: :all)
37
+ end
38
+
39
+ # Default (visible-only) matcher: passes only because the placeholder is hidden
40
+ # via opacity (not visibility/display), so Capybara still sees it.
41
+ Then /^the placeholder image should reserve layout$/ do
42
+ expect(page).to have_css("turbo-frame .async-variant-processing img")
43
+ end
44
+
45
+ Given /^the retry affordance is visible to everyone$/ do
46
+ ActiveStorage::AsyncVariants.retry_visible_if { true }
47
+ end
48
+
49
+ Given /^the retry affordance is hidden$/ do
50
+ ActiveStorage::AsyncVariants.retry_visible_if { false }
51
+ end
52
+
53
+ Given /^the retry affordance requires a signed-in user$/ do
54
+ ActiveStorage::AsyncVariants.retry_visible_if { current_user.present? }
55
+ end
56
+
57
+ When /^I sign in as the (admin|user)$/ do |_kind|
58
+ visit "/session/#{@user.id}"
59
+ end
60
+
61
+ When /^I visit the avatar page for the :(\w+) variant$/ do |variant_name|
62
+ visit "/avatars/#{@user.id}/#{variant_name}"
63
+ end
64
+
65
+ Then /^the page should contain a turbo-frame$/ do
66
+ expect(page).to have_css("turbo-frame")
67
+ end
68
+
69
+ Then /^the page should NOT contain a turbo-frame$/ do
70
+ expect(page).to have_no_css("turbo-frame")
71
+ expect(page).to have_css("img")
72
+ end
73
+
74
+ Then /^the frame should render the (failed|processing|processed) state$/ do |state|
75
+ expect(page).to have_css("turbo-frame .async-variant-#{state}")
76
+ end
77
+
78
+ Then /^the retry affordance should be visible$/ do
79
+ expect(page).to have_css("async-variant-retry", visible: :all)
80
+ end
81
+
82
+ Then /^the retry affordance should NOT be visible$/ do
83
+ expect(page).to have_no_css("async-variant-retry", visible: :all)
84
+ end
85
+
86
+ Then /^the page should not have navigated away$/ do
87
+ expect(page).to have_css("h1#page-marker")
88
+ end
89
+
90
+ Then /^the failure dialog should be open$/ do
91
+ expect(page).to have_css("async-variant-retry", visible: :all)
92
+ open = page.evaluate_script(<<~JS)
93
+ (() => {
94
+ const host = document.querySelector("async-variant-retry")
95
+ if (!host || !host.shadowRoot) return false
96
+ const dialog = host.shadowRoot.querySelector("dialog")
97
+ return dialog ? dialog.open : false
98
+ })()
99
+ JS
100
+ expect(open).to eq(true)
101
+ end
102
+
103
+ Then /^the failure dialog should contain "([^"]+)"$/ do |text|
104
+ contains = page.evaluate_script(<<~JS)
105
+ (() => {
106
+ const host = document.querySelector("async-variant-retry")
107
+ if (!host || !host.shadowRoot) return false
108
+ return host.shadowRoot.textContent.includes(#{text.inspect})
109
+ })()
110
+ JS
111
+ expect(contains).to eq(true)
112
+ end
113
+
114
+ # Wait for Turbo to swap the frame and our shim to attach the shadow root.
115
+ def wait_for_shadow!
116
+ Timeout.timeout(5) do
117
+ loop do
118
+ attached = page.evaluate_script("!!document.querySelector('async-variant-retry')?.shadowRoot")
119
+ break if attached
120
+ sleep 0.05
121
+ end
122
+ end
123
+ end
124
+
125
+ When /^I click the retry opener$/ do
126
+ wait_for_shadow!
127
+ page.evaluate_script(<<~JS)
128
+ document.querySelector("async-variant-retry").shadowRoot.querySelector(".opener").click()
129
+ JS
130
+ end
131
+
132
+ When /^I click "Retry processing"$/ do
133
+ wait_for_shadow!
134
+ page.evaluate_script(<<~JS)
135
+ document.querySelector("async-variant-retry").shadowRoot.querySelector("footer button").click()
136
+ JS
137
+ end
138
+
139
+ When /^I close the failure dialog$/ do
140
+ wait_for_shadow!
141
+ page.evaluate_script(<<~JS)
142
+ document.querySelector("async-variant-retry").shadowRoot.querySelector("dialog > button[type=button]").click()
143
+ JS
144
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV["RAILS_ENV"] ||= "test"
4
+ require File.expand_path("../../spec/dummy/config/environment", __dir__)
5
+
6
+ require "capybara"
7
+ require "capybara/cucumber"
8
+ require "capybara/cuprite"
9
+ require_relative "../../spec/support/schema"
10
+
11
+ DummySchema.load!
12
+
13
+ Capybara.register_driver(:cuprite) do |app|
14
+ Capybara::Cuprite::Driver.new(app, window_size: [1200, 900], timeout: 10, process_timeout: 20, js_errors: true)
15
+ end
16
+ Capybara.app = Rails.application
17
+ Capybara.javascript_driver = :cuprite
18
+ Capybara.default_driver = :cuprite
19
+ Capybara.server = :puma, { Silent: true }
20
+
21
+ ActionController::Base.allow_forgery_protection = false
22
+
23
+ # Test adapter just records enqueued jobs -- we don't want them to actually run
24
+ # during browser tests (they'd call real transformers and modify variant_records
25
+ # behind the test's back). The retry step seeds the new state explicitly.
26
+ ActiveJob::Base.queue_adapter = :test
27
+
28
+ Before { DummySchema.cleanup! }
@@ -0,0 +1,18 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "irb"
7
+ gem "rake", "~> 13.0"
8
+ gem "rspec", "~> 3.0"
9
+ gem "simplecov", require: false
10
+ gem "rspec-rails"
11
+ gem "rails", "~> 7.2.0"
12
+ gem "sqlite3"
13
+ gem "cucumber"
14
+ gem "capybara"
15
+ gem "cuprite"
16
+ gem "puma"
17
+
18
+ gemspec path: "../"
@@ -0,0 +1,18 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "irb"
7
+ gem "rake", "~> 13.0"
8
+ gem "rspec", "~> 3.0"
9
+ gem "simplecov", require: false
10
+ gem "rspec-rails"
11
+ gem "rails", "~> 8.0.0"
12
+ gem "sqlite3"
13
+ gem "cucumber"
14
+ gem "capybara"
15
+ gem "cuprite"
16
+ gem "puma"
17
+
18
+ gemspec path: "../"
@@ -0,0 +1,18 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "irb"
7
+ gem "rake", "~> 13.0"
8
+ gem "rspec", "~> 3.0"
9
+ gem "simplecov", require: false
10
+ gem "rspec-rails"
11
+ gem "rails", "~> 8.1.0"
12
+ gem "sqlite3"
13
+ gem "cucumber"
14
+ gem "capybara"
15
+ gem "cuprite"
16
+ gem "puma"
17
+
18
+ gemspec path: "../"
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module AsyncVariants
5
+ # Prepended onto ActionView::Helpers::AssetTagHelper. Adds `async:` and
6
+ # `direct:` options to image_tag/video_tag; routes unprocessed variants
7
+ # through a <turbo-frame> whose body lives in StatesController#show.
8
+ module AssetTagHelperExtension
9
+ include ActiveStorage::AsyncVariants::Helper
10
+
11
+ def image_tag(source, options = {})
12
+ async_variant_tag(:image, [source], options) do |urls, opts|
13
+ super(urls.first, opts)
14
+ end
15
+ end
16
+
17
+ def video_tag(*sources)
18
+ options = sources.extract_options!
19
+ async_variant_tag(:video, sources, options) do |urls, opts|
20
+ super(*urls, opts)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def async_variant_tag(kind, sources, options)
27
+ options = options.symbolize_keys
28
+ async = options.delete(:async)
29
+ direct = options.delete(:direct)
30
+ return yield(sources, options) if !async && !direct
31
+
32
+ variant, *rest = sources
33
+ assert_async_variant!(variant)
34
+
35
+ if async && !async_variant_processed_inline?(variant)
36
+ async_variant_turbo_frame(variant, kind:, direct:, html_options: options)
37
+ else
38
+ yield [async_variant_resolved_src(variant, direct:), *rest], options
39
+ end
40
+ end
41
+
42
+ def assert_async_variant!(source)
43
+ unless source.is_a?(ActiveStorage::VariantWithRecord) || source.is_a?(ActiveStorage::Preview)
44
+ raise ArgumentError, "image_tag/video_tag with async:/direct: requires an ActiveStorage::VariantWithRecord or Preview, got #{source.class}"
45
+ end
46
+ end
47
+
48
+ def async_variant_turbo_frame(variant, kind:, direct:, html_options:)
49
+ content_tag(
50
+ :"turbo-frame",
51
+ "",
52
+ id: async_variant_frame_id(variant),
53
+ src: async_variant_frame_src(variant, kind: kind, direct: direct, html_options: html_options),
54
+ refresh: "morph",
55
+ )
56
+ end
57
+ end
58
+ end
59
+ end