active_storage-async_variants 0.8.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.
@@ -1,347 +0,0 @@
1
- const CIRCLE_RADIUS = 40; // fallback r attribute; CSS shrinks it to fit the stroke
2
- const PATH_LENGTH = 100; // normalize the arc so dash math is independent of the radius
3
- const RATE_HZ = 30; // optimistic auto-advance tick frequency
4
-
5
- const STYLES = `
6
- :host {
7
- --progress-color: #2E7D32;
8
- --error-color: #7a242f;
9
- --indeterminate-color: #999;
10
- --track-color: #333333;
11
- --progress-duration: 120ms;
12
- --indeterminate-duration: 1.5s;
13
- --bar-height: 32px;
14
- --bar-padding: 8px;
15
- --circular-size: 64px;
16
- --circular-thickness: 16;
17
- display: block;
18
- overflow: hidden;
19
- background: var(--track-color);
20
- border: 1px solid #999;
21
- border-radius: 4px;
22
- min-height: var(--bar-height);
23
- padding: var(--bar-padding);
24
- font-size: 13px;
25
- align-content: center;
26
- box-sizing: border-box;
27
- position: relative;
28
- }
29
- .bar {
30
- position: absolute;
31
- top: 0;
32
- left: 0;
33
- height: 100%;
34
- width: 0%;
35
- background: var(--progress-color);
36
- transition: width var(--progress-duration) ease;
37
- z-index: 1;
38
- }
39
- .text{position: relative; z-index: 2;}
40
- :host([error]) .bar {
41
- background: var(--error-color);
42
- }
43
-
44
- /* Indeterminate (linear): no percent set — a fixed-width segment sweeping across the track. */
45
- :host(:not([percent])) .bar {
46
- background: var(--indeterminate-color);
47
- width: 40%;
48
- animation: indeterminate-linear var(--indeterminate-duration) infinite linear;
49
- }
50
-
51
- @keyframes indeterminate-linear {
52
- 0% { transform: translateX(-100%); }
53
- 100% { transform: translateX(250%); }
54
- }
55
-
56
- /* Circular mode: the host box styling is for the linear track, so drop it. */
57
- :host([mode="circular"]) {
58
- background: transparent;
59
- border: none;
60
- overflow: visible;
61
- padding: 0;
62
- min-height: 0;
63
- }
64
-
65
- .circular {
66
- position: relative;
67
- display: inline-flex;
68
- width: var(--circular-size);
69
- height: var(--circular-size);
70
- }
71
-
72
- .ring {
73
- width: 100%;
74
- height: 100%;
75
- transform: rotate(-90deg);
76
- }
77
-
78
- .ring circle {
79
- fill: none;
80
- stroke-width: var(--circular-thickness);
81
- /* Shrink the radius so the stroke's outer edge always lands inside the
82
- 100x100 viewBox, no matter how thick --circular-thickness is. */
83
- r: calc(49px - var(--circular-thickness) * 0.5px);
84
- }
85
-
86
- .track {
87
- stroke: var(--track-color);
88
- }
89
-
90
- .progress-ring {
91
- stroke: var(--progress-color);
92
- stroke-linecap: round;
93
- stroke-dasharray: ${PATH_LENGTH};
94
- stroke-dashoffset: ${PATH_LENGTH};
95
- transition: stroke-dashoffset var(--progress-duration) ease;
96
- }
97
-
98
- :host(:not([percent])) .progress-ring {
99
- stroke: var(--indeterminate-color);
100
- }
101
-
102
- /* The X of the error glyph; revealed by [error]. */
103
- .error-mark {
104
- display: none;
105
- fill: none;
106
- stroke: var(--error-color);
107
- stroke-width: var(--circular-thickness);
108
- stroke-linecap: round;
109
- }
110
-
111
- .label {
112
- position: absolute;
113
- inset: 0;
114
- display: flex;
115
- align-items: center;
116
- justify-content: center;
117
- color: #fff;
118
- mix-blend-mode: difference;
119
- }
120
-
121
- /* When the host carries its own inline styling (e.g. a color override), opt
122
- out of the blend trick and just inherit, so the override wins predictably. */
123
- :host([style]) .label {
124
- mix-blend-mode: initial;
125
- color: inherit;
126
- }
127
-
128
- /* Indeterminate (circular): no percent set — spin a fixed arc around the ring. */
129
- :host(:not([percent])) .ring {
130
- animation: indeterminate-rotate var(--indeterminate-duration) infinite linear;
131
- }
132
-
133
- :host(:not([percent])) .progress-ring {
134
- stroke-dashoffset: ${PATH_LENGTH * 0.75};
135
- }
136
-
137
- @keyframes indeterminate-rotate {
138
- 0% { transform: rotate(-90deg); }
139
- 100% { transform: rotate(270deg); }
140
- }
141
-
142
- /* Error (circular): static full ring + X. Last so it beats the indeterminate rules. */
143
- :host([error]) .track { stroke: var(--error-color); }
144
- :host([error]) .progress-ring { display: none; }
145
- :host([error]) .ring { animation: none; }
146
- :host([error]) .error-mark { display: block; }
147
- `;
148
-
149
- const LINEAR_HTML = `
150
- <div class="bar"></div>
151
- <div class="text"><slot></slot></div>
152
- `;
153
-
154
- const CIRCULAR_HTML = `
155
- <div class="circular">
156
- <svg class="ring" viewBox="0 0 100 100">
157
- <circle class="track" cx="50" cy="50" r="${CIRCLE_RADIUS}"></circle>
158
- <circle class="progress-ring" cx="50" cy="50" r="${CIRCLE_RADIUS}" pathLength="${PATH_LENGTH}"></circle>
159
- <path class="error-mark" d="M37 37 L63 63 M63 37 L37 63"></path>
160
- </svg>
161
- <span class="label"><slot></slot></span>
162
- </div>
163
- `;
164
-
165
- let styleSheet = null;
166
- function getStyleSheet() {
167
- if (!styleSheet) {
168
- styleSheet = new CSSStyleSheet();
169
- styleSheet.replaceSync(STYLES);
170
- }
171
- return styleSheet;
172
- }
173
-
174
- function clampPercent(value) {
175
- const number = Number(value);
176
- if (Number.isNaN(number)) {
177
- throw new TypeError(`progress-bar: percent must be numeric, got ${JSON.stringify(value)}`);
178
- }
179
- return Math.min(100, Math.max(0, number));
180
- }
181
-
182
- function parseRate(value) {
183
- const number = Number(value);
184
- if (Number.isNaN(number)) {
185
- throw new TypeError(`progress-bar: rate must be numeric, got ${JSON.stringify(value)}`);
186
- }
187
- return number;
188
- }
189
-
190
- class ProgressBar extends HTMLElement {
191
- constructor() {
192
- super();
193
- this.attachShadow({ mode: 'open' });
194
- this._percent = null;
195
- this._renderedMode = null;
196
- this._rateTimer = null;
197
- }
198
-
199
- connectedCallback() {
200
- this.render();
201
- this.setAttribute('role', 'progressbar');
202
- this.setAttribute('aria-valuemin', '0');
203
- this.setAttribute('aria-valuemax', '100');
204
- this.updateBar();
205
- this._syncTicker();
206
- }
207
-
208
- disconnectedCallback() {
209
- this._stopTicker();
210
- }
211
-
212
- get percent() {
213
- return this._percent;
214
- }
215
-
216
- set percent(value) {
217
- if (value === null || value === undefined) {
218
- this.removeAttribute('percent');
219
- } else {
220
- this.setAttribute('percent', clampPercent(value));
221
- }
222
- }
223
-
224
- get error() {
225
- return this.hasAttribute('error');
226
- }
227
-
228
- set error(value) {
229
- this.toggleAttribute('error', Boolean(value));
230
- }
231
-
232
- get rate() {
233
- const value = this.getAttribute('rate');
234
- return value === null ? null : Number(value);
235
- }
236
-
237
- set rate(value) {
238
- if (value === null || value === undefined) {
239
- this.removeAttribute('rate');
240
- } else {
241
- this.setAttribute('rate', parseRate(value));
242
- }
243
- }
244
-
245
- get indeterminate() {
246
- return !this.hasAttribute('percent');
247
- }
248
-
249
- get mode() {
250
- return this.getAttribute('mode') === 'circular' ? 'circular' : 'linear';
251
- }
252
-
253
- set mode(value) {
254
- this.setAttribute('mode', value);
255
- }
256
-
257
- static get observedAttributes() {
258
- return ['percent', 'mode', 'rate'];
259
- }
260
-
261
- attributeChangedCallback(name, oldValue, newValue) {
262
- if (name === 'percent') {
263
- this._percent = newValue === null ? null : clampPercent(newValue);
264
- }
265
- if (name === 'rate' && newValue !== null) {
266
- parseRate(newValue);
267
- }
268
- if (name === 'mode') {
269
- this.render();
270
- }
271
- if (name === 'percent' || name === 'rate') {
272
- this._syncTicker();
273
- }
274
- this.updateBar();
275
- }
276
-
277
- updateBar() {
278
- if (this.mode === 'circular') {
279
- const ring = this.shadowRoot?.querySelector('.progress-ring');
280
- if (ring) {
281
- ring.style.strokeDashoffset = this.indeterminate
282
- ? ''
283
- : String(PATH_LENGTH * (1 - this._percent / 100));
284
- }
285
- } else {
286
- const bar = this.shadowRoot?.querySelector('.bar');
287
- if (bar) {
288
- // Leave width to the indeterminate CSS animation when unknown.
289
- bar.style.width = this.indeterminate ? '' : `${this._percent}%`;
290
- }
291
- }
292
-
293
- if (this.indeterminate) {
294
- this.removeAttribute('aria-valuenow');
295
- } else {
296
- this.setAttribute('aria-valuenow', String(this._percent));
297
- }
298
- }
299
-
300
- // Optimistic auto-advance: while a nonzero rate is set on a determinate bar,
301
- // creep percent forward on its own. Indeterminate bars ignore rate entirely.
302
- _syncTicker() {
303
- const rate = Number(this.getAttribute('rate'));
304
- const shouldRun = this.isConnected && !this.indeterminate && Number.isFinite(rate) && rate !== 0;
305
- if (shouldRun && this._rateTimer === null) {
306
- this._rateTimer = setInterval(() => this._tick(), 1000 / RATE_HZ);
307
- } else if (!shouldRun) {
308
- this._stopTicker();
309
- }
310
- }
311
-
312
- _stopTicker() {
313
- if (this._rateTimer !== null) {
314
- clearInterval(this._rateTimer);
315
- this._rateTimer = null;
316
- }
317
- }
318
-
319
- _tick() {
320
- const rate = Number(this.getAttribute('rate'));
321
- const next = clampPercent(this._percent + rate / RATE_HZ);
322
- if (next !== this._percent) {
323
- this.percent = next;
324
- }
325
- // Hitting the bound it's heading toward retires the rate; a later percent
326
- // change can re-arm it.
327
- if (next === 0 || next === 100) {
328
- this.removeAttribute('rate');
329
- }
330
- }
331
-
332
- render() {
333
- const mode = this.mode;
334
- if (this._renderedMode === mode) {
335
- return;
336
- }
337
- this._renderedMode = mode;
338
- this.shadowRoot.adoptedStyleSheets = [getStyleSheet()];
339
- this.shadowRoot.innerHTML = mode === 'circular' ? CIRCULAR_HTML : LINEAR_HTML;
340
- }
341
- }
342
-
343
- if (!customElements.get('progress-bar')) {
344
- customElements.define('progress-bar', ProgressBar);
345
- }
346
-
347
- export default ProgressBar;
@@ -1,11 +0,0 @@
1
- // Declarative shadow roots are only attached by the initial HTML parser, not by
2
- // fragment insertion (innerHTML / Turbo frame swaps), so promote them on connect.
3
- customElements.define("async-variant-retry", class extends HTMLElement {
4
- connectedCallback() {
5
- if (this.shadowRoot) return
6
- const template = this.querySelector(":scope > template[shadowrootmode]")
7
- if (!template) return
8
- this.attachShadow({ mode: template.getAttribute("shadowrootmode") }).append(template.content)
9
- template.remove()
10
- }
11
- })
@@ -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,71 +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
- # 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
@@ -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()">&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>
@@ -1 +0,0 @@
1
- <%= render "active_storage/async_variants/states/processing", local_assigns %>
@@ -1,5 +0,0 @@
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 %>
@@ -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, 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>
@@ -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 %>
@@ -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