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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/assets/builds/avo/application.css +175 -27
  4. data/app/assets/builds/avo/application.js +88 -88
  5. data/app/assets/builds/avo/application.js.map +4 -4
  6. data/app/assets/stylesheets/application.css +23 -0
  7. data/app/assets/stylesheets/css/components/field-wrapper.css +15 -14
  8. data/app/assets/stylesheets/css/components/manual_frame.css +38 -0
  9. data/app/assets/stylesheets/css/components/modal.css +22 -10
  10. data/app/components/avo/fields/has_many_field/show_component.html.erb +6 -2
  11. data/app/components/avo/fields/has_one_field/show_component.html.erb +12 -8
  12. data/app/components/avo/manual_frame_component.html.erb +60 -0
  13. data/app/components/avo/manual_frame_component.rb +73 -0
  14. data/app/components/avo/tab_group_component.html.erb +14 -1
  15. data/app/components/avo/tab_group_component.rb +15 -7
  16. data/app/helpers/avo/application_helper.rb +29 -0
  17. data/app/javascript/application.js +6 -0
  18. data/app/javascript/js/controllers/manual_frame_controller.js +160 -0
  19. data/app/javascript/js/controllers.js +2 -0
  20. data/app/views/avo/actions/show.html.erb +1 -1
  21. data/app/views/layouts/avo/application.html.erb +5 -3
  22. data/lib/avo/concerns/frame_loading_mode.rb +59 -0
  23. data/lib/avo/fields/frame_base_field.rb +13 -0
  24. data/lib/avo/fields/many_frame_base_field.rb +12 -1
  25. data/lib/avo/resources/items/tab.rb +3 -0
  26. data/lib/avo/version.rb +1 -1
  27. data/lib/generators/avo/templates/locales/avo.ar.yml +5 -0
  28. data/lib/generators/avo/templates/locales/avo.de.yml +5 -0
  29. data/lib/generators/avo/templates/locales/avo.en.yml +5 -0
  30. data/lib/generators/avo/templates/locales/avo.es.yml +5 -0
  31. data/lib/generators/avo/templates/locales/avo.fr.yml +5 -0
  32. data/lib/generators/avo/templates/locales/avo.it.yml +5 -0
  33. data/lib/generators/avo/templates/locales/avo.ja.yml +5 -0
  34. data/lib/generators/avo/templates/locales/avo.nb.yml +5 -0
  35. data/lib/generators/avo/templates/locales/avo.nl.yml +5 -0
  36. data/lib/generators/avo/templates/locales/avo.nn.yml +5 -0
  37. data/lib/generators/avo/templates/locales/avo.pl.yml +5 -0
  38. data/lib/generators/avo/templates/locales/avo.pt-BR.yml +5 -0
  39. data/lib/generators/avo/templates/locales/avo.pt.yml +5 -0
  40. data/lib/generators/avo/templates/locales/avo.ro.yml +5 -0
  41. data/lib/generators/avo/templates/locales/avo.ru.yml +5 -0
  42. data/lib/generators/avo/templates/locales/avo.tr.yml +5 -0
  43. data/lib/generators/avo/templates/locales/avo.ua.yml +5 -0
  44. data/lib/generators/avo/templates/locales/avo.zh-TW.yml +5 -0
  45. data/lib/generators/avo/templates/locales/avo.zh.yml +5 -0
  46. 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
- .all-fields-stacked .field-wrapper,
89
- .field-wrapper.field-wrapper--stacked {
90
- @apply md:flex-col md:items-start gap-y-2 py-3;
88
+ .field-wrapper {
89
+ @variant field-stacked {
90
+ @apply flex-col items-start gap-y-2 py-3;
91
91
 
92
- .field-wrapper__label {
93
- @apply md:w-full min-h-0 pt-0;
94
- }
92
+ .field-wrapper__label {
93
+ @apply w-full min-h-0 pt-0;
94
+ }
95
95
 
96
- .field-wrapper__label-help {
97
- @apply pb-0;
98
- }
96
+ .field-wrapper__label-help {
97
+ @apply pb-0;
98
+ }
99
99
 
100
- .field-wrapper__content {
101
- @apply pt-0 px-4 w-full;
102
- }
100
+ .field-wrapper__content {
101
+ @apply pt-0 px-4 w-full;
102
+ }
103
103
 
104
- .field-wrapper__content-wrapper {
105
- @apply w-full;
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 shrink-0 w-full;
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 flex flex-col flex-1 gap-4 items-start pt-0 px-0 min-h-64;
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 size-full;
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
- <%= turbo_frame_tag @field.turbo_frame, src: @field.frame_url, loading: turbo_frame_loading, target: :_top, class: "block" do %>
2
- <%= render(Avo::LoadingComponent.new(title: @field.plural_name)) %>
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
- <%= turbo_frame_tag @field.turbo_frame, src: @field.frame_url, loading: turbo_frame_loading, target: "_top", class: "block" do %>
3
- <%= render(Avo::LoadingComponent.new(title: @field.name)) %>
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: 'tabler/outline/link',
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('avo.attach_item', item: @field.name.humanize(capitalize: false)) %>
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: 'tabler/outline/plus',
24
- 'data-target': 'create',
25
- 'data-turbo-frame': '_top',
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('avo.create_new_item', item: @field.name.humanize(capitalize: false) ) %>
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.lazy_load && view.display? %>
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] = helpers.resource_path(
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
+ }