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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +24 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/Appraisals +11 -0
- data/CHANGELOG.md +8 -0
- data/CLAUDE.md +35 -0
- data/LICENSE.txt +21 -0
- data/README.md +89 -0
- data/Rakefile +27 -0
- data/app/assets/javascripts/progress-bar.js +347 -0
- data/app/assets/javascripts/retry.js +11 -0
- data/app/assets/stylesheets/progress.css +35 -0
- data/app/assets/stylesheets/retry.css +98 -0
- data/app/controllers/active_storage/async_variants/states_controller.rb +71 -0
- data/app/views/active_storage/async_variants/states/_failed.html.erb +44 -0
- data/app/views/active_storage/async_variants/states/_pending.html.erb +1 -0
- data/app/views/active_storage/async_variants/states/_processed.html.erb +5 -0
- data/app/views/active_storage/async_variants/states/_processing.html.erb +15 -0
- data/app/views/active_storage/async_variants/states/show.html.erb +10 -0
- data/config/routes.rb +20 -0
- data/features/retry_flow.feature +20 -0
- data/features/retry_visibility.feature +23 -0
- data/features/state_rendering.feature +40 -0
- data/features/step_definitions/dummy_steps.rb +144 -0
- data/features/support/env.rb +28 -0
- data/gemfiles/rails_7.2.gemfile +18 -0
- data/gemfiles/rails_8.0.gemfile +18 -0
- data/gemfiles/rails_8.1.gemfile +18 -0
- data/lib/active_storage/async_variants/asset_tag_helper_extension.rb +59 -0
- data/lib/active_storage/async_variants/helper.rb +80 -0
- data/lib/active_storage/async_variants/ui/version.rb +9 -0
- data/lib/active_storage/async_variants/ui.rb +45 -0
- data/log/.gitkeep +0 -0
- 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()">×</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,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
|