active_storage-async_variants 0.7.0 → 0.9.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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +5 -48
- data/Rakefile +1 -20
- data/config/routes.rb +0 -9
- data/db/migrate/20260608000001_add_created_at_to_variant_records.rb +7 -0
- data/gemfiles/rails_7.2.gemfile +0 -4
- data/gemfiles/rails_8.0.gemfile +0 -4
- data/gemfiles/rails_8.1.gemfile +0 -4
- data/lib/active_storage/async_variants/variant_with_record_extension.rb +9 -0
- data/lib/active_storage/async_variants/version.rb +1 -1
- data/lib/active_storage/async_variants.rb +3 -35
- metadata +3 -47
- data/app/assets/javascripts/progress-bar.js +0 -281
- data/app/assets/javascripts/retry.js +0 -11
- data/app/assets/stylesheets/progress.css +0 -35
- data/app/assets/stylesheets/retry.css +0 -98
- data/app/controllers/active_storage/async_variants/states_controller.rb +0 -62
- data/app/views/active_storage/async_variants/states/_failed.html.erb +0 -44
- data/app/views/active_storage/async_variants/states/_pending.html.erb +0 -1
- data/app/views/active_storage/async_variants/states/_processed.html.erb +0 -5
- data/app/views/active_storage/async_variants/states/_processing.html.erb +0 -15
- data/app/views/active_storage/async_variants/states/show.html.erb +0 -10
- data/features/retry_flow.feature +0 -20
- data/features/retry_visibility.feature +0 -23
- data/features/state_rendering.feature +0 -40
- data/features/step_definitions/dummy_steps.rb +0 -144
- data/features/support/env.rb +0 -28
- data/lib/active_storage/async_variants/asset_tag_helper_extension.rb +0 -59
- data/lib/active_storage/async_variants/helper.rb +0 -78
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/* turbo-frame is inline by default; make it and the wrapper block-level so the
|
|
2
|
-
media box (and the progress bar centered on it) coincides with the parent
|
|
3
|
-
figure box, instead of sitting in a taller line box from the baseline gap --
|
|
4
|
-
otherwise an overlay centered on the figure lands off-center from the ring. */
|
|
5
|
-
turbo-frame:has(> .async-variant-processing),
|
|
6
|
-
turbo-frame:has(> .async-variant-failed) {
|
|
7
|
-
display: block;
|
|
8
|
-
}
|
|
9
|
-
.async-variant-processing,
|
|
10
|
-
.async-variant-failed {
|
|
11
|
-
position: relative;
|
|
12
|
-
display: block;
|
|
13
|
-
}
|
|
14
|
-
/* Present to reserve the variant's box, but rendered invisible via opacity
|
|
15
|
-
rather than visibility/display -- so it still has layout and stays queryable
|
|
16
|
-
by Capybara. The progress bar floats over it. block drops the baseline
|
|
17
|
-
descender so the ring centers on the box, not a few px below it. */
|
|
18
|
-
.async-variant-processing img,
|
|
19
|
-
.async-variant-processing video,
|
|
20
|
-
.async-variant-failed img,
|
|
21
|
-
.async-variant-failed video {
|
|
22
|
-
opacity: 0.001;
|
|
23
|
-
display: block;
|
|
24
|
-
}
|
|
25
|
-
.async-variant-progress {
|
|
26
|
-
position: absolute;
|
|
27
|
-
top: 50%;
|
|
28
|
-
left: 50%;
|
|
29
|
-
transform: translate(-50%, -50%);
|
|
30
|
-
--circular-size: 80px;
|
|
31
|
-
--progress-color: #2E7D32;
|
|
32
|
-
--indeterminate-color: #2E7D32;
|
|
33
|
-
--track-color: rgba(0, 0, 0, 0.25);
|
|
34
|
-
--error-color: #ff7c81;
|
|
35
|
-
}
|
|
@@ -1,98 +0,0 @@
|
|
|
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; }
|
|
@@ -1,62 +0,0 @@
|
|
|
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
|
-
before_action :set_variant
|
|
15
|
-
|
|
16
|
-
def show
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def retry
|
|
20
|
-
@variant.blob.variant_records.where(
|
|
21
|
-
variation_digest: @variant.variation.digest,
|
|
22
|
-
state: "failed",
|
|
23
|
-
).destroy_all
|
|
24
|
-
@variant.enqueue!
|
|
25
|
-
|
|
26
|
-
redirect_to Rails.application.routes.url_helpers.async_variant_state_path(
|
|
27
|
-
signed_blob_id: params[:signed_blob_id],
|
|
28
|
-
variation_key: params[:variation_key],
|
|
29
|
-
kind: params[:kind],
|
|
30
|
-
direct: params[:direct],
|
|
31
|
-
opts: async_variant_html_options.presence,
|
|
32
|
-
), status: :see_other
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
helper_method :async_variant_kind, :async_variant_direct?, :async_variant_html_options
|
|
36
|
-
|
|
37
|
-
private
|
|
38
|
-
|
|
39
|
-
def set_variant
|
|
40
|
-
blob = ActiveStorage::Blob.find_signed!(params[:signed_blob_id])
|
|
41
|
-
variation = ActiveStorage::Variation.decode(params[:variation_key])
|
|
42
|
-
@variant = blob.variant(variation)
|
|
43
|
-
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
44
|
-
head :not_found
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def async_variant_kind
|
|
48
|
-
params[:kind].to_s == "video" ? :video : :image
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def async_variant_direct?
|
|
52
|
-
ActiveModel::Type::Boolean.new.cast(params[:direct])
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def async_variant_html_options
|
|
56
|
-
params[:opts]
|
|
57
|
-
&.permit(*ActiveStorage::AsyncVariants::PASS_THROUGH_HTML_OPTIONS)
|
|
58
|
-
.to_h
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
end
|
|
@@ -1,44 +0,0 @@
|
|
|
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()">×</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>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<%= render "active_storage/async_variants/states/processing", local_assigns %>
|
|
@@ -1,15 +0,0 @@
|
|
|
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 %>
|
|
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>
|
|
@@ -1,10 +0,0 @@
|
|
|
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/features/retry_flow.feature
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
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
|
|
@@ -1,23 +0,0 @@
|
|
|
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
|
|
@@ -1,40 +0,0 @@
|
|
|
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
|
|
@@ -1,144 +0,0 @@
|
|
|
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
|
data/features/support/env.rb
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
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! }
|
|
@@ -1,59 +0,0 @@
|
|
|
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
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module ActiveStorage
|
|
4
|
-
module AsyncVariants
|
|
5
|
-
# View/controller helper methods. Included in the AssetTagHelperExtension
|
|
6
|
-
# (so image_tag/video_tag can use them) and added to StatesController via
|
|
7
|
-
# `helper ActiveStorage::AsyncVariants::Helper` (so the state partials can
|
|
8
|
-
# use them).
|
|
9
|
-
module Helper
|
|
10
|
-
# 1x1 transparent GIF: reserves the variant's box without fetching the
|
|
11
|
-
# original through the representations path on every poll.
|
|
12
|
-
PLACEHOLDER_SRC = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
|
13
|
-
|
|
14
|
-
def async_variant_placeholder_tag(variant, html_options = {}, kind: :image)
|
|
15
|
-
opts = async_variant_box_dimensions(variant)
|
|
16
|
-
.merge(html_options.symbolize_keys.except(:src, :controls, :autoplay, :preload, :poster))
|
|
17
|
-
# A video placeholder carries no <source>, so it reserves the box
|
|
18
|
-
# without loading anything -- same zero-network intent as the GIF.
|
|
19
|
-
kind == :video ? content_tag(:video, "", opts) : image_tag(PLACEHOLDER_SRC, opts)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def async_variant_box_dimensions(variant)
|
|
23
|
-
resize = variant.variation.transformations
|
|
24
|
-
.values_at(:resize_to_limit, :resize_to_fit, :resize_to_fill)
|
|
25
|
-
.compact.first
|
|
26
|
-
resize.is_a?(Array) ? { width: resize[0], height: resize[1] } : {}
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# In test with non-bucket-backed services, the gem defers to vanilla
|
|
30
|
-
# ActiveStorage (synchronous vips transform) -- inline rendering keeps
|
|
31
|
-
# those environments simple. Otherwise, only inline a normal <img> when
|
|
32
|
-
# the variant has reached the processed terminal state.
|
|
33
|
-
def async_variant_processed_inline?(variant)
|
|
34
|
-
!variant.blob.bucket_backed? || variant.async_state == "processed"
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def async_variant_resolved_src(variant, direct:)
|
|
38
|
-
if direct && variant.async_state == "processed"
|
|
39
|
-
async_variant_direct_url(variant)
|
|
40
|
-
else
|
|
41
|
-
variant.processed if variant.blob.bucket_backed?
|
|
42
|
-
async_variant_representation_path(variant)
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def async_variant_frame_id(variant)
|
|
47
|
-
digest = variant.variation.digest.gsub(/[^a-zA-Z0-9_-]/, "")
|
|
48
|
-
"async-variant-#{variant.blob.id}-#{digest}"
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def async_variant_frame_src(variant, kind:, direct:, html_options: {})
|
|
52
|
-
async_variant_state_path(
|
|
53
|
-
signed_blob_id: variant.blob.signed_id,
|
|
54
|
-
variation_key: variant.variation.key,
|
|
55
|
-
kind:,
|
|
56
|
-
direct:,
|
|
57
|
-
opts: html_options.slice(*PASS_THROUGH_HTML_OPTIONS),
|
|
58
|
-
)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def async_variant_direct_url(variant)
|
|
62
|
-
if cdn = ActiveStorage::AsyncVariants.cdn_host
|
|
63
|
-
"#{cdn}/#{variant.key}"
|
|
64
|
-
else
|
|
65
|
-
variant.image.url
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def async_variant_representation_path(variant)
|
|
70
|
-
Rails.application.routes.url_helpers.rails_blob_representation_path(
|
|
71
|
-
variant.blob.signed_id,
|
|
72
|
-
variant.variation.key,
|
|
73
|
-
variant.blob.filename.to_s,
|
|
74
|
-
)
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
end
|