avo 4.0.0.beta.45 → 4.0.0.beta.46
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/Gemfile.lock +1 -1
- data/app/assets/builds/avo/application.css +175 -27
- data/app/assets/builds/avo/application.js +88 -88
- data/app/assets/builds/avo/application.js.map +4 -4
- data/app/assets/stylesheets/application.css +23 -0
- data/app/assets/stylesheets/css/components/field-wrapper.css +15 -14
- data/app/assets/stylesheets/css/components/manual_frame.css +38 -0
- data/app/assets/stylesheets/css/components/modal.css +22 -10
- data/app/components/avo/fields/has_many_field/show_component.html.erb +6 -2
- data/app/components/avo/fields/has_one_field/show_component.html.erb +12 -8
- data/app/components/avo/manual_frame_component.html.erb +60 -0
- data/app/components/avo/manual_frame_component.rb +73 -0
- data/app/components/avo/tab_group_component.html.erb +14 -1
- data/app/components/avo/tab_group_component.rb +15 -7
- data/app/helpers/avo/application_helper.rb +29 -0
- data/app/javascript/application.js +6 -0
- data/app/javascript/js/controllers/manual_frame_controller.js +160 -0
- data/app/javascript/js/controllers.js +2 -0
- data/app/views/avo/actions/show.html.erb +1 -1
- data/app/views/layouts/avo/application.html.erb +5 -3
- data/lib/avo/concerns/frame_loading_mode.rb +59 -0
- data/lib/avo/fields/frame_base_field.rb +13 -0
- data/lib/avo/fields/many_frame_base_field.rb +12 -1
- data/lib/avo/resources/items/tab.rb +3 -0
- data/lib/avo/version.rb +1 -1
- data/lib/generators/avo/templates/locales/avo.ar.yml +5 -0
- data/lib/generators/avo/templates/locales/avo.de.yml +5 -0
- data/lib/generators/avo/templates/locales/avo.en.yml +5 -0
- data/lib/generators/avo/templates/locales/avo.es.yml +5 -0
- data/lib/generators/avo/templates/locales/avo.fr.yml +5 -0
- data/lib/generators/avo/templates/locales/avo.it.yml +5 -0
- data/lib/generators/avo/templates/locales/avo.ja.yml +5 -0
- data/lib/generators/avo/templates/locales/avo.nb.yml +5 -0
- data/lib/generators/avo/templates/locales/avo.nl.yml +5 -0
- data/lib/generators/avo/templates/locales/avo.nn.yml +5 -0
- data/lib/generators/avo/templates/locales/avo.pl.yml +5 -0
- data/lib/generators/avo/templates/locales/avo.pt-BR.yml +5 -0
- data/lib/generators/avo/templates/locales/avo.pt.yml +5 -0
- data/lib/generators/avo/templates/locales/avo.ro.yml +5 -0
- data/lib/generators/avo/templates/locales/avo.ru.yml +5 -0
- data/lib/generators/avo/templates/locales/avo.tr.yml +5 -0
- data/lib/generators/avo/templates/locales/avo.ua.yml +5 -0
- data/lib/generators/avo/templates/locales/avo.zh-TW.yml +5 -0
- data/lib/generators/avo/templates/locales/avo.zh.yml +5 -0
- metadata +6 -1
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/* @layer base, components, utilities; */
|
|
2
2
|
@import "tailwindcss";
|
|
3
3
|
|
|
4
|
+
@source not "*/tmp/cache";
|
|
5
|
+
@source not "*/tmp/pids";
|
|
6
|
+
@source not "*/tmp/screenshots";
|
|
7
|
+
@source not "*/tmp/storage";
|
|
8
|
+
|
|
4
9
|
/* TODO: Figure out a way to add those dynamically */
|
|
5
10
|
/* Find a long term solution for this. */
|
|
6
11
|
/* Basically this is a hack to make sure that we dont have specificity issues with the packages stylesheets. */
|
|
@@ -87,6 +92,23 @@
|
|
|
87
92
|
@custom-variant resource-edit-view (&:where(.resource-edit-view *));
|
|
88
93
|
@custom-variant resource-new-view (&:where(.resource-new-view *));
|
|
89
94
|
|
|
95
|
+
/* Field wrappers use the same stacked layout below md, when the whole page
|
|
96
|
+
forces stacked fields, and when an individual field is explicitly stacked.
|
|
97
|
+
The component styles live in field-wrapper.css under `@variant field-stacked`. */
|
|
98
|
+
@custom-variant field-stacked {
|
|
99
|
+
@media (width < 48rem) {
|
|
100
|
+
@slot;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
&:is(.all-fields-stacked .field-wrapper, .field-wrapper.field-wrapper--stacked) {
|
|
104
|
+
@slot;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
:is(.all-fields-stacked .field-wrapper, .field-wrapper.field-wrapper--stacked) & {
|
|
108
|
+
@slot;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
90
112
|
/* Used to determine if the filters buttons is displayed. */
|
|
91
113
|
@custom-variant index-missing-resources (&:where(.index-missing-resources *));
|
|
92
114
|
|
|
@@ -160,6 +182,7 @@
|
|
|
160
182
|
@import "./css/components/discreet_information.css";
|
|
161
183
|
@import "./css/components/header_menu.css";
|
|
162
184
|
@import "./css/components/hotkey.css";
|
|
185
|
+
@import "./css/components/manual_frame.css";
|
|
163
186
|
@import "./css/components/modal.css";
|
|
164
187
|
@import "./css/components/filters.css";
|
|
165
188
|
@import "./css/css-animations.css";
|
|
@@ -85,24 +85,25 @@
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/* Stacked modifier */
|
|
88
|
-
.
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
.field-wrapper {
|
|
89
|
+
@variant field-stacked {
|
|
90
|
+
@apply flex-col items-start gap-y-2 py-3;
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
.field-wrapper__label {
|
|
93
|
+
@apply w-full min-h-0 pt-0;
|
|
94
|
+
}
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
.field-wrapper__label-help {
|
|
97
|
+
@apply pb-0;
|
|
98
|
+
}
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
.field-wrapper__content {
|
|
101
|
+
@apply pt-0 px-4 w-full;
|
|
102
|
+
}
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
.field-wrapper__content-wrapper {
|
|
105
|
+
@apply w-full;
|
|
106
|
+
}
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
.manual-frame {
|
|
2
|
+
@apply relative overflow-hidden rounded-lg border border-secondary px-4 py-3;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.manual-frame__overlay {
|
|
6
|
+
@apply absolute inset-0 z-10 flex items-center justify-center bg-primary/80 backdrop-blur-sm;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.manual-frame__loading {
|
|
10
|
+
@apply inline-flex items-center gap-2 rounded-lg border border-secondary bg-primary px-3 py-2 text-sm font-medium text-content shadow-panel;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.manual-frame__loading-icon {
|
|
14
|
+
@apply size-4 animate-spin text-content-secondary;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* --- Error / Retry state -------------------------------------------------- */
|
|
18
|
+
|
|
19
|
+
.manual-frame__error-row {
|
|
20
|
+
@apply flex items-center justify-between gap-3;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.manual-frame__error-message {
|
|
24
|
+
@apply inline-flex min-w-0 items-center gap-2 truncate text-sm text-content-secondary;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.manual-frame__error-icon {
|
|
28
|
+
@apply size-4 shrink-0 text-danger-content;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* Dev-only "open this frame" note. */
|
|
32
|
+
.manual-frame__error-note {
|
|
33
|
+
@apply mt-2 text-xs text-content-secondary;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.manual-frame__error-link {
|
|
37
|
+
@apply font-medium underline underline-offset-2;
|
|
38
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
/* size-full + m-0 + border-0/p-0/bg-transparent reset the popover UA chrome
|
|
18
18
|
(fit-content sizing, auto margins, border, padding, background) so the
|
|
19
19
|
element stays a transparent, full-viewport flex container that centers the card. */
|
|
20
|
-
@apply fixed inset-0 size-full m-0 border-0 p-0 bg-transparent justify-center items-center;
|
|
20
|
+
@apply fixed inset-0 size-full m-0 border-0 p-0 bg-transparent justify-center items-center overflow-hidden;
|
|
21
21
|
|
|
22
22
|
/* Enter/leave transition duration, shared by the card and the backdrop.
|
|
23
23
|
Keep in sync with TRANSITION_MS in base_modal_controller.js. */
|
|
@@ -59,16 +59,20 @@
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
.modal__card-container {
|
|
62
|
-
@apply flex flex-col items-center justify-center relative
|
|
62
|
+
@apply flex flex-col items-center justify-center relative min-h-0 size-full;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
.modal__card {
|
|
66
|
-
@apply relative w-full shrink-0 flex flex-col;
|
|
66
|
+
@apply relative w-full shrink-0 flex flex-col min-h-0 overflow-hidden;
|
|
67
67
|
|
|
68
68
|
/* Never exceed the viewport — keeps a tall (e.g. :auto height) modal fully
|
|
69
69
|
on screen so its body can scroll instead of being clipped by the centered
|
|
70
|
-
overlay.
|
|
70
|
+
overlay. The width clamp does the same horizontally: the size classes set a
|
|
71
|
+
preferred width (e.g. w-xl), but on a narrow phone screen it caps to the
|
|
72
|
+
viewport instead of overflowing and being clipped on both sides. The gutter
|
|
73
|
+
is tight on mobile to reclaim width and opens up to 2rem from sm up. */
|
|
71
74
|
max-height: calc(100dvh - 2rem);
|
|
75
|
+
max-width: calc(100dvw - 1rem);
|
|
72
76
|
|
|
73
77
|
box-shadow: var(--shadow-modal);
|
|
74
78
|
|
|
@@ -82,6 +86,14 @@
|
|
|
82
86
|
transform var(--modal-transition-duration) ease-out;
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
/* Open the gutter back up from sm up, where the extra breathing room reads
|
|
90
|
+
better and the clamp rarely engages anyway. */
|
|
91
|
+
@media (min-width: 640px) {
|
|
92
|
+
.modal__card {
|
|
93
|
+
max-width: calc(100dvw - 2rem);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
85
97
|
.modal:popover-open .modal__card {
|
|
86
98
|
opacity: 1;
|
|
87
99
|
transform: scale(1);
|
|
@@ -126,19 +138,19 @@
|
|
|
126
138
|
its wrapper fill the (capped) modal height and shrink, so the body's
|
|
127
139
|
overflow-auto kicks in while the header and footer stay pinned. */
|
|
128
140
|
.modal__card > .card {
|
|
129
|
-
@apply w-full flex-1 min-h-0;
|
|
141
|
+
@apply w-full flex-1 min-h-0 overflow-hidden;
|
|
130
142
|
}
|
|
131
143
|
|
|
132
144
|
.modal__card > .card > .card__wrapper {
|
|
133
|
-
@apply w-full flex flex-col flex-1 min-h-0;
|
|
145
|
+
@apply w-full flex flex-col flex-1 min-h-0 overflow-hidden;
|
|
134
146
|
}
|
|
135
147
|
|
|
136
148
|
.modal__card .card__header {
|
|
137
|
-
@apply py-3 px-4;
|
|
149
|
+
@apply py-3 px-4 shrink-0;
|
|
138
150
|
}
|
|
139
151
|
|
|
140
152
|
.modal__card > .card > .card__wrapper > .card__body {
|
|
141
|
-
@apply
|
|
153
|
+
@apply block flex-1 pt-0 px-0 min-h-0 overflow-auto border-0 rounded-none bg-transparent;
|
|
142
154
|
}
|
|
143
155
|
|
|
144
156
|
.modal__title {
|
|
@@ -158,7 +170,7 @@
|
|
|
158
170
|
}
|
|
159
171
|
|
|
160
172
|
.modal__body-content {
|
|
161
|
-
@apply
|
|
173
|
+
@apply w-full min-h-48 rounded-card-wrapper border border-tertiary bg-primary;
|
|
162
174
|
}
|
|
163
175
|
|
|
164
176
|
/* Belongs-to "Create new" embeds an edit form in the modal. Drop the nested
|
|
@@ -169,7 +181,7 @@
|
|
|
169
181
|
}
|
|
170
182
|
|
|
171
183
|
.modal__controls {
|
|
172
|
-
@apply flex items-center justify-between gap-2 py-2 px-3 w-full;
|
|
184
|
+
@apply flex items-center justify-between gap-2 py-2 px-3 w-full shrink-0;
|
|
173
185
|
}
|
|
174
186
|
|
|
175
187
|
/* When the controls hold only buttons, nudge the horizontal padding in so the
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
<%= render
|
|
1
|
+
<% if @field.manual? && @field.view.display? && !helpers.manual_frame_remembered?(@field.frame_url, @field.auto_load_for) %>
|
|
2
|
+
<%= render Avo::ManualFrameComponent.new(@field.turbo_frame, deferred_url: @field.frame_url, title: @field.plural_name, description: @field.description(loading_type: :manual), auto_load_for: @field.auto_load_for, cookie_name: helpers.manual_frame_cookie_name(@field.frame_url)) %>
|
|
3
|
+
<% else %>
|
|
4
|
+
<%= turbo_frame_tag @field.turbo_frame, src: @field.frame_url, loading: (@field.lazy_loading_mode? ? "lazy" : turbo_frame_loading), target: :_top, class: "block" do %>
|
|
5
|
+
<%= render(Avo::LoadingComponent.new(title: @field.plural_name)) %>
|
|
6
|
+
<% end %>
|
|
3
7
|
<% end %>
|
|
@@ -1,31 +1,35 @@
|
|
|
1
1
|
<% if @field.value %>
|
|
2
|
-
|
|
3
|
-
<%= render
|
|
2
|
+
<% if @field.manual? && @field.view.display? && !helpers.manual_frame_remembered?(@field.frame_url, @field.auto_load_for) %>
|
|
3
|
+
<%= render Avo::ManualFrameComponent.new(@field.turbo_frame, deferred_url: @field.frame_url, title: @field.name, description: @field.description(loading_type: :manual), auto_load_for: @field.auto_load_for, cookie_name: helpers.manual_frame_cookie_name(@field.frame_url)) %>
|
|
4
|
+
<% else %>
|
|
5
|
+
<%= turbo_frame_tag @field.turbo_frame, src: @field.frame_url, loading: (@field.lazy_loading_mode? ? "lazy" : turbo_frame_loading), target: "_top", class: "block" do %>
|
|
6
|
+
<%= render(Avo::LoadingComponent.new(title: @field.name)) %>
|
|
7
|
+
<% end %>
|
|
4
8
|
<% end %>
|
|
5
9
|
<% else %>
|
|
6
10
|
<%= render ui.panel(title: @field.name) do |panel| %>
|
|
7
11
|
<% panel.with_controls do %>
|
|
8
12
|
<% if can_attach? %>
|
|
9
13
|
<%= a_link attach_path,
|
|
10
|
-
icon:
|
|
14
|
+
icon: "tabler/outline/link",
|
|
11
15
|
color: :primary,
|
|
12
16
|
style: :text,
|
|
13
17
|
data: {
|
|
14
18
|
turbo_frame: Avo::MODAL_FRAME_ID,
|
|
15
19
|
target: :attach
|
|
16
20
|
} do %>
|
|
17
|
-
<%= t(
|
|
21
|
+
<%= t("avo.attach_item", item: @field.name.humanize(capitalize: false)) %>
|
|
18
22
|
<% end %>
|
|
19
23
|
<% end %>
|
|
20
24
|
|
|
21
25
|
<% if can_see_the_create_button? %>
|
|
22
26
|
<%= a_link create_path,
|
|
23
|
-
icon:
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
icon: "tabler/outline/plus",
|
|
28
|
+
"data-target": "create",
|
|
29
|
+
"data-turbo-frame": "_top",
|
|
26
30
|
style: :primary,
|
|
27
31
|
color: :accent do %>
|
|
28
|
-
<%= t(
|
|
32
|
+
<%= t("avo.create_new_item", item: @field.name.humanize(capitalize: false)) %>
|
|
29
33
|
<% end %>
|
|
30
34
|
<% end %>
|
|
31
35
|
<% end %>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<%# Manual (on-demand) Turbo Frame: no `src`; the Load button supplies it on click. %>
|
|
2
|
+
<%= turbo_frame_tag @frame_id,
|
|
3
|
+
target: :_top,
|
|
4
|
+
class: "block",
|
|
5
|
+
data: {
|
|
6
|
+
manual_frame: true,
|
|
7
|
+
controller: "manual-frame",
|
|
8
|
+
manual_frame_url_value: @deferred_url,
|
|
9
|
+
manual_frame_auto_load_for_value: @auto_load_for,
|
|
10
|
+
manual_frame_cookie_name_value: @cookie_name
|
|
11
|
+
} do %>
|
|
12
|
+
<%# Placeholder state. %>
|
|
13
|
+
<div class="<%= class_names("manual-frame", @classes) %>" data-manual-frame-target="placeholder">
|
|
14
|
+
<%= render ui.panel_header(title: label, description: @description) do |header| %>
|
|
15
|
+
<% header.with_controls do %>
|
|
16
|
+
<%= a_button color: :primary,
|
|
17
|
+
icon: "tabler/outline/ripple-down",
|
|
18
|
+
aria: {label: load_label},
|
|
19
|
+
data: {action: "manual-frame#load"} do %>
|
|
20
|
+
<%= load_label %>
|
|
21
|
+
<% end %>
|
|
22
|
+
<% end %>
|
|
23
|
+
<% end %>
|
|
24
|
+
|
|
25
|
+
<div
|
|
26
|
+
class="manual-frame__overlay"
|
|
27
|
+
data-manual-frame-target="loading"
|
|
28
|
+
aria-live="polite"
|
|
29
|
+
hidden>
|
|
30
|
+
<div class="manual-frame__loading">
|
|
31
|
+
<%= svg "tabler/outline/refresh", class: "manual-frame__loading-icon" %>
|
|
32
|
+
<span><%= t("avo.loading") %></span>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<%# Error/Retry state: a quiet inline message (danger-colored icon + muted %>
|
|
38
|
+
<%# copy), a low-key Retry, and a dev-only link to the failing URL. %>
|
|
39
|
+
<div
|
|
40
|
+
class="manual-frame"
|
|
41
|
+
data-manual-frame-target="error"
|
|
42
|
+
aria-live="polite"
|
|
43
|
+
hidden>
|
|
44
|
+
<div class="manual-frame__error-row">
|
|
45
|
+
<span class="manual-frame__error-message">
|
|
46
|
+
<%= svg "tabler/outline/alert-circle", class: "manual-frame__error-icon" %>
|
|
47
|
+
<%= t("avo.failed_to_load_item", item: label) %>
|
|
48
|
+
</span>
|
|
49
|
+
|
|
50
|
+
<%= a_button color: :gray,
|
|
51
|
+
icon: "tabler/outline/refresh",
|
|
52
|
+
aria: {label: retry_label},
|
|
53
|
+
data: {action: "manual-frame#retry"} do %>
|
|
54
|
+
<%= t("avo.retry") %>
|
|
55
|
+
<% end %>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<%= dev_details_note %>
|
|
59
|
+
</div>
|
|
60
|
+
<% end %>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Renders a `<turbo-frame>` with NO `src` that defers loading until the user
|
|
4
|
+
# clicks the "Load" button. Used by manual (`loading: :manual`) tabs and
|
|
5
|
+
# associations on Show pages.
|
|
6
|
+
#
|
|
7
|
+
# The frame carries the deferred URL in `data-manual-frame-url-value` for the
|
|
8
|
+
# `manual-frame` Stimulus controller, which sets the frame `src` on click and
|
|
9
|
+
# renders an inline error + Retry on failure. It is marked `data-manual-frame`
|
|
10
|
+
# as an identifying hook.
|
|
11
|
+
#
|
|
12
|
+
# States (single component, three states):
|
|
13
|
+
# - placeholder: title + a "Load <title>" button
|
|
14
|
+
# - loading: `Avo::LoadingComponent` spinner while the request is in flight
|
|
15
|
+
# - error: inline message + Retry
|
|
16
|
+
class Avo::ManualFrameComponent < Avo::BaseComponent
|
|
17
|
+
# @param frame_id [String] the `<turbo-frame>` id (positional)
|
|
18
|
+
# @param deferred_url [String] the URL the controller loads on click
|
|
19
|
+
# @param title [String] label source; callers pass an already display-ready
|
|
20
|
+
# name (a field's `plural_name`/`name` or a tab title), used verbatim
|
|
21
|
+
# @param description [String, nil] the field/tab description, shown beside the
|
|
22
|
+
# title when present (already resolved by the caller)
|
|
23
|
+
# @param auto_load_for [Integer, nil] seconds the browser should remember an
|
|
24
|
+
# opened frame and auto-load it on return (sliding window). `nil` disables
|
|
25
|
+
# the memory — the frame stays a click-to-load placeholder every visit.
|
|
26
|
+
# @param cookie_name [String, nil] the cookie the Stimulus controller writes on
|
|
27
|
+
# a successful load so the server can render this frame without the Load
|
|
28
|
+
# button on the next visit. Paired with `auto_load_for`; both come from the
|
|
29
|
+
# `manual_frame_*` application helpers. `nil` when there's no memory window.
|
|
30
|
+
# @param classes [String, nil] extra CSS classes for the placeholder container
|
|
31
|
+
prop :frame_id, kind: :positional
|
|
32
|
+
prop :deferred_url
|
|
33
|
+
prop :title
|
|
34
|
+
prop :description
|
|
35
|
+
prop :auto_load_for
|
|
36
|
+
prop :cookie_name
|
|
37
|
+
prop :classes
|
|
38
|
+
|
|
39
|
+
# The display label. Callers already hand us presentation-ready, translated
|
|
40
|
+
# text (field name / tab title), so we use it as-is. Never `humanize`, which
|
|
41
|
+
# would mangle custom or translated names ("Order Items" -> "Order items").
|
|
42
|
+
def label
|
|
43
|
+
@title.to_s
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# "Load <title>", interpolated so the full phrase is translatable, matching
|
|
47
|
+
# the codebase's `attach_item`/`create_new_item` convention.
|
|
48
|
+
def load_label
|
|
49
|
+
t("avo.load_item", item: label)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The Retry button's aria-label: "Retry <title>" (the visible copy is just
|
|
53
|
+
# "Retry"; the aria-label scopes it to this frame for screen readers).
|
|
54
|
+
def retry_label
|
|
55
|
+
t("avo.retry_item", item: label)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# In development only, a link straight to the deferred URL so a developer can
|
|
59
|
+
# open the failing frame on its own and read the real error / stack trace
|
|
60
|
+
# (the inline error state otherwise swallows the response). Mirrors the
|
|
61
|
+
# dev-only link in `avo/home/failed_to_load.html.erb`. Returns nil elsewhere.
|
|
62
|
+
def dev_details_note
|
|
63
|
+
return unless Rails.env.development? && @deferred_url.present?
|
|
64
|
+
|
|
65
|
+
helpers.tag.p(class: "manual-frame__error-note") do
|
|
66
|
+
helpers.safe_join([
|
|
67
|
+
"Follow ",
|
|
68
|
+
helpers.link_to("this link", @deferred_url, target: "_blank", rel: "noopener", class: "manual-frame__error-link"),
|
|
69
|
+
" for more details about the issue and how to fix it."
|
|
70
|
+
])
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -28,7 +28,20 @@
|
|
|
28
28
|
<% end %>
|
|
29
29
|
|
|
30
30
|
<% if !tab.is_empty? %>
|
|
31
|
-
<% if tab.
|
|
31
|
+
<% if tab.manual? && view.display? %>
|
|
32
|
+
<% if !is_not_loaded?(tab) %>
|
|
33
|
+
<%= turbo_frame_tag tab.turbo_frame_id(parent: group), target: :_top, class: "block" do %>
|
|
34
|
+
<%= render Avo::TabContentComponent.new tab:, resource:, index:, form:, view: %>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% elsif helpers.manual_frame_remembered?(manual_frame_url(tab), tab.auto_load_for) %>
|
|
37
|
+
<%# Remembered: render a real (lazy) frame so it loads with no Load button. %>
|
|
38
|
+
<%= turbo_frame_tag tab.turbo_frame_id(parent: group), **frame_args(tab) do %>
|
|
39
|
+
<%= render Avo::LoadingComponent.new(title: tab.title) %>
|
|
40
|
+
<% end %>
|
|
41
|
+
<% else %>
|
|
42
|
+
<%= render Avo::ManualFrameComponent.new(tab.turbo_frame_id(parent: group), deferred_url: manual_frame_url(tab), title: tab.title, description: tab.description, auto_load_for: tab.auto_load_for, cookie_name: helpers.manual_frame_cookie_name(manual_frame_url(tab))) %>
|
|
43
|
+
<% end %>
|
|
44
|
+
<% elsif (tab.lazy_load || tab.lazy_loading_mode?) && view.display? %>
|
|
32
45
|
<%= turbo_frame_tag tab.turbo_frame_id(parent: group), **frame_args(tab) do %>
|
|
33
46
|
<% if is_not_loaded?(tab) %>
|
|
34
47
|
<%= render Avo::LoadingComponent.new(title: tab.title) %>
|
|
@@ -27,18 +27,26 @@ class Avo::TabGroupComponent < Avo::BaseComponent
|
|
|
27
27
|
|
|
28
28
|
if is_not_loaded?(tab)
|
|
29
29
|
args[:loading] = :lazy
|
|
30
|
-
args[:src] =
|
|
31
|
-
resource: resource,
|
|
32
|
-
record: resource.record,
|
|
33
|
-
keep_query_params: true,
|
|
34
|
-
active_tab_title: tab.title,
|
|
35
|
-
tab_turbo_frame: tab.turbo_frame_id(parent: group)
|
|
36
|
-
)
|
|
30
|
+
args[:src] = manual_frame_url(tab)
|
|
37
31
|
end
|
|
38
32
|
|
|
39
33
|
args
|
|
40
34
|
end
|
|
41
35
|
|
|
36
|
+
# The deferred load URL for a tab's turbo frame. Mirrors the `src` the lazy
|
|
37
|
+
# branch builds in `frame_args`, so a manual tab's Load button fetches the
|
|
38
|
+
# exact same proven URL — carrying `tab_turbo_frame` so `is_not_loaded?` is
|
|
39
|
+
# false on the framed re-render and the real `TabContentComponent` renders.
|
|
40
|
+
def manual_frame_url(tab)
|
|
41
|
+
helpers.resource_path(
|
|
42
|
+
resource: resource,
|
|
43
|
+
record: resource.record,
|
|
44
|
+
keep_query_params: true,
|
|
45
|
+
active_tab_title: tab.title,
|
|
46
|
+
tab_turbo_frame: tab.turbo_frame_id(parent: group)
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
42
50
|
def is_not_loaded?(tab)
|
|
43
51
|
params[:tab_turbo_frame] != tab.turbo_frame_id(parent: group)
|
|
44
52
|
end
|
|
@@ -5,6 +5,35 @@ module Avo
|
|
|
5
5
|
|
|
6
6
|
def ui = Avo::UIInstance
|
|
7
7
|
|
|
8
|
+
# The cookie name that remembers an opened manual frame. Derived from the
|
|
9
|
+
# frame's deferred URL (which already encodes resource + record + frame), so
|
|
10
|
+
# the memory is scoped per record + association/tab. Hashed to keep the name
|
|
11
|
+
# short and cookie-safe. The `manual-frame` Stimulus controller writes this
|
|
12
|
+
# same name client-side on a successful load (see manual_frame_controller.js).
|
|
13
|
+
def manual_frame_cookie_name(url)
|
|
14
|
+
"amf_#{Digest::MD5.hexdigest(url.to_s)}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Whether this manual frame was opened recently enough to skip the placeholder
|
|
18
|
+
# and render a real auto-loading `<turbo-frame src>` (so the Load button never
|
|
19
|
+
# appears). Presence-based: the cookie carries `max-age`, so the browser drops
|
|
20
|
+
# it when the window lapses — a present cookie means "still remembered".
|
|
21
|
+
#
|
|
22
|
+
# Sliding: every render that finds it remembered refreshes the cookie, so the
|
|
23
|
+
# window keeps moving forward as long as the user keeps coming back.
|
|
24
|
+
#
|
|
25
|
+
# Returns false (and touches nothing) for plain `loading: :manual` frames,
|
|
26
|
+
# which carry no `auto_load_for` window.
|
|
27
|
+
def manual_frame_remembered?(url, auto_load_for)
|
|
28
|
+
return false if auto_load_for.blank?
|
|
29
|
+
|
|
30
|
+
name = manual_frame_cookie_name(url)
|
|
31
|
+
return false if request.cookies[name].blank?
|
|
32
|
+
|
|
33
|
+
cookies[name] = {value: "1", path: "/", max_age: auto_load_for.to_i, same_site: :lax}
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
|
|
8
37
|
# Active Storage URL helpers raise UrlGenerationError when a blob's filename
|
|
9
38
|
# is blank (the route requires a :filename segment). Use a synthetic filename
|
|
10
39
|
# so attach mode and other callers still get a routable URL; return nil only
|
|
@@ -102,6 +102,12 @@ document.addEventListener('turbo:frame-load', () => {
|
|
|
102
102
|
})
|
|
103
103
|
|
|
104
104
|
document.addEventListener('turbo:before-fetch-response', async (e) => {
|
|
105
|
+
// Manual frames handle their own deferred-load failures inline and call
|
|
106
|
+
// `stopImmediatePropagation()` (see manual_frame_controller.js), so this
|
|
107
|
+
// global handler never sees them. We intentionally do NOT skip on the
|
|
108
|
+
// `data-manual-frame` attribute: once a manual frame has loaded, a later
|
|
109
|
+
// navigation inside its content that 500s should still fall through to the
|
|
110
|
+
// /failed_to_load page like any other frame.
|
|
105
111
|
if (e.detail.fetchResponse.response.status === 500) {
|
|
106
112
|
const { id, src } = e.target
|
|
107
113
|
// Don't try to redirect to failed to load if this is already a redirection to failed to load and crashed somewhere.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
import { toggleHidden } from '../helpers/toggle_hidden'
|
|
3
|
+
|
|
4
|
+
// Drives a manual (on-demand) Turbo Frame: the frame renders with NO `src`,
|
|
5
|
+
// and clicking the Load button sets `src` from `data-manual-frame-url-value`
|
|
6
|
+
// so the deferred content is fetched only on demand.
|
|
7
|
+
//
|
|
8
|
+
// The controller is attached to the `<turbo-frame>` element itself, so
|
|
9
|
+
// `this.element` is the frame.
|
|
10
|
+
//
|
|
11
|
+
// We deliberately do NOT set `loading="lazy"`, so neither Turbo's lazy
|
|
12
|
+
// auto-fetch nor the #886 strip handler touches this frame.
|
|
13
|
+
//
|
|
14
|
+
// Auto-load memory: when the frame is configured with a window
|
|
15
|
+
// (`auto_load_for`), a successful load writes a short-lived cookie. The SERVER
|
|
16
|
+
// reads that cookie on the next render and emits a real `<turbo-frame src>`
|
|
17
|
+
// (no placeholder, no button) — so the controller never has to "auto-click".
|
|
18
|
+
// All this controller does is write/refresh the cookie when the user opens the
|
|
19
|
+
// frame; the decision to skip the button lives entirely on the server.
|
|
20
|
+
export default class extends Controller {
|
|
21
|
+
static targets = ['placeholder', 'loading', 'error']
|
|
22
|
+
|
|
23
|
+
static values = {
|
|
24
|
+
url: String,
|
|
25
|
+
// The cookie name the server reads to decide whether to skip the Load
|
|
26
|
+
// button on the next visit. Empty when the frame has no memory window.
|
|
27
|
+
cookieName: String,
|
|
28
|
+
// Seconds the memory cookie should live (its max-age). 0 (the Stimulus
|
|
29
|
+
// default when the attribute is absent) = no memory.
|
|
30
|
+
autoLoadFor: Number,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
connect() {
|
|
34
|
+
this.pending = false
|
|
35
|
+
this.awaitingFrameLoad = false
|
|
36
|
+
this.onRequestError = this.handleRequestError.bind(this)
|
|
37
|
+
this.onBeforeFetchResponse = this.handleBeforeFetchResponse.bind(this)
|
|
38
|
+
this.onFrameLoad = this.handleFrameLoad.bind(this)
|
|
39
|
+
|
|
40
|
+
this.element.addEventListener('turbo:fetch-request-error', this.onRequestError)
|
|
41
|
+
this.element.addEventListener('turbo:before-fetch-response', this.onBeforeFetchResponse)
|
|
42
|
+
this.element.addEventListener('turbo:frame-load', this.onFrameLoad)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
disconnect() {
|
|
46
|
+
this.element.removeEventListener('turbo:fetch-request-error', this.onRequestError)
|
|
47
|
+
this.element.removeEventListener('turbo:before-fetch-response', this.onBeforeFetchResponse)
|
|
48
|
+
this.element.removeEventListener('turbo:frame-load', this.onFrameLoad)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Click handler for the Load button.
|
|
52
|
+
load() {
|
|
53
|
+
if (this.pending) return // ignore repeat clicks while a request is in flight
|
|
54
|
+
|
|
55
|
+
this.pending = true
|
|
56
|
+
this.awaitingFrameLoad = false
|
|
57
|
+
this.showLoading()
|
|
58
|
+
|
|
59
|
+
// Setting `src` triggers Turbo to fetch and swap in the deferred content.
|
|
60
|
+
// On Retry the `src` is already this URL (a failed load leaves it in place),
|
|
61
|
+
// and re-setting an attribute to its current value is a no-op, so reload()
|
|
62
|
+
// to force a fresh fetch in that case.
|
|
63
|
+
if (this.element.getAttribute('src') === this.urlValue) {
|
|
64
|
+
this.element.reload()
|
|
65
|
+
} else {
|
|
66
|
+
this.element.src = this.urlValue
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Click handler for the Retry button: drop the error state and re-issue.
|
|
71
|
+
retry() {
|
|
72
|
+
this.load()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
showLoading() {
|
|
76
|
+
this.setHidden(this.placeholderTarget, false)
|
|
77
|
+
this.setHidden(this.loadingTarget, false)
|
|
78
|
+
this.setHidden(this.errorTarget, true)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
showError() {
|
|
82
|
+
this.setHidden(this.placeholderTarget, true)
|
|
83
|
+
this.setHidden(this.loadingTarget, true)
|
|
84
|
+
this.setHidden(this.errorTarget, false)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
setHidden(element, hidden) {
|
|
88
|
+
if (!element) return
|
|
89
|
+
if (element.hasAttribute('hidden') === hidden) return
|
|
90
|
+
|
|
91
|
+
toggleHidden(element)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- Lifecycle ------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
// Our deferred load succeeded and Turbo swapped the real content in. Move
|
|
97
|
+
// focus into the frame so keyboard / screen-reader users aren't dropped to
|
|
98
|
+
// <body> when the Load button is removed from the DOM (R9).
|
|
99
|
+
handleFrameLoad() {
|
|
100
|
+
if (!this.awaitingFrameLoad) return // not our load (or a later navigation inside content)
|
|
101
|
+
|
|
102
|
+
this.awaitingFrameLoad = false
|
|
103
|
+
|
|
104
|
+
// Remember the open frame (and slide the window forward) — only on success,
|
|
105
|
+
// so a failed load never sets the memory.
|
|
106
|
+
this.rememberOpen()
|
|
107
|
+
|
|
108
|
+
this.element.setAttribute('tabindex', '-1')
|
|
109
|
+
this.element.focus({ preventScroll: true })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Network failure / timeout (no HTTP response at all).
|
|
113
|
+
handleRequestError(event) {
|
|
114
|
+
if (!this.pending) return // a later in-frame navigation, let Turbo handle it
|
|
115
|
+
|
|
116
|
+
this.pending = false
|
|
117
|
+
event.stopImmediatePropagation()
|
|
118
|
+
this.showError()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// A response arrived; our deferred load is resolved either way. If it's not OK
|
|
122
|
+
// (status >= 400), stop Turbo from rendering the error body into the frame,
|
|
123
|
+
// stop the global `application.js` handler from hijacking it, and show our
|
|
124
|
+
// inline error instead.
|
|
125
|
+
handleBeforeFetchResponse(event) {
|
|
126
|
+
if (!this.pending) return // after our load resolves, later navigations are Turbo's
|
|
127
|
+
|
|
128
|
+
const response = event.detail?.fetchResponse?.response
|
|
129
|
+
if (!response) return
|
|
130
|
+
|
|
131
|
+
this.pending = false
|
|
132
|
+
|
|
133
|
+
if (response.ok) {
|
|
134
|
+
// Success: handleFrameLoad finishes the sequence (focus + memory) once
|
|
135
|
+
// Turbo renders the frame. `pending` is cleared HERE (not there) so that
|
|
136
|
+
// if `turbo:frame-load` never fires (e.g. the response is missing the
|
|
137
|
+
// matching frame), later in-frame navigations are never mistaken for the
|
|
138
|
+
// deferred load and still reach the global failure handler.
|
|
139
|
+
this.awaitingFrameLoad = true
|
|
140
|
+
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
event.preventDefault()
|
|
145
|
+
event.stopImmediatePropagation()
|
|
146
|
+
this.showError()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- Auto-load memory -----------------------------------------------------
|
|
150
|
+
|
|
151
|
+
// Write/refresh the cookie that tells the server "this frame is open, render
|
|
152
|
+
// it without the Load button next time." `max-age` gives it a sliding TTL —
|
|
153
|
+
// the browser drops it when the window lapses, and each successful load
|
|
154
|
+
// rewrites it with a fresh window. No-ops when the frame has no memory window.
|
|
155
|
+
rememberOpen() {
|
|
156
|
+
if (!this.cookieNameValue || this.autoLoadForValue <= 0) return
|
|
157
|
+
|
|
158
|
+
document.cookie = `${this.cookieNameValue}=1; path=/; max-age=${this.autoLoadForValue}; samesite=lax`
|
|
159
|
+
}
|
|
160
|
+
}
|