From 0316b848ebe397154ab8e2148fc191395b323f2c Mon Sep 17 00:00:00 2001 From: Dream <42954461+eureka928@users.noreply.github.com> Date: Fri, 23 Jan 2026 05:08:38 -0500 Subject: [PATCH] fix: add auto-refresh for processing exports on index page (#715) Wrap export list in turbo_frame_tag with conditional polling attributes. When exports are pending/processing, page polls every 3 seconds for updates. Add turbo_frame: _top to download/delete buttons for proper frame handling. --- .../controllers/polling_controller.js | 64 +++++ app/views/family_exports/index.html.erb | 236 +++++++++--------- 2 files changed, 186 insertions(+), 114 deletions(-) create mode 100644 app/javascript/controllers/polling_controller.js diff --git a/app/javascript/controllers/polling_controller.js b/app/javascript/controllers/polling_controller.js new file mode 100644 index 000000000..97cc252c1 --- /dev/null +++ b/app/javascript/controllers/polling_controller.js @@ -0,0 +1,64 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="polling" +// Automatically refreshes a turbo frame at a specified interval +export default class extends Controller { + static values = { + url: String, + interval: { type: Number, default: 3000 }, + }; + + connect() { + this.startPolling(); + } + + disconnect() { + this.stopPolling(); + } + + startPolling() { + if (!this.hasUrlValue) return; + + this.poll = setInterval(() => { + this.refresh(); + }, this.intervalValue); + } + + stopPolling() { + if (this.poll) { + clearInterval(this.poll); + this.poll = null; + } + } + + async refresh() { + try { + const response = await fetch(this.urlValue, { + headers: { + Accept: "text/vnd.turbo-stream.html", + "Turbo-Frame": this.element.id, + }, + }); + + if (response.ok) { + const html = await response.text(); + const template = document.createElement("template"); + template.innerHTML = html; + + const newFrame = template.content.querySelector( + `turbo-frame#${this.element.id}`, + ); + if (newFrame) { + this.element.innerHTML = newFrame.innerHTML; + + // Check if we should stop polling (no more pending/processing exports) + if (!newFrame.hasAttribute("data-polling-url-value")) { + this.stopPolling(); + } + } + } + } catch (error) { + console.error("Polling error:", error); + } + } +} diff --git a/app/views/family_exports/index.html.erb b/app/views/family_exports/index.html.erb index fec621b8e..906bfa6e3 100644 --- a/app/views/family_exports/index.html.erb +++ b/app/views/family_exports/index.html.erb @@ -1,122 +1,130 @@ <%= settings_section title: t(".title") do %>
- <%= t("family_exports.table.title") %> -
- · -<%= @pagy.count %>
-| - <%= t("family_exports.table.header.date") %> - | -- <%= t("family_exports.table.header.filename") %> - | -- <%= t("family_exports.table.header.status") %> - | -- <%= t("family_exports.table.header.actions") %> - | -
|---|---|---|---|
| - - <%= l(export.created_at, format: :long) %> - - | -- - <%= export.filename %> - - | -- <% if export.processing? || export.pending? %> - <%= render "shared/badge" do %> - <%= t("family_exports.table.row.status.in_progress") %> - <% end %> - <% elsif export.completed? %> - <%= render "shared/badge", color: "success" do %> - <%= t("family_exports.table.row.status.complete") %> - <% end %> - <% elsif export.failed? %> - <%= render "shared/badge", color: "error" do %> - <%= t("family_exports.table.row.status.failed") %> - <% end %> - <% end %> - | -
- <% if export.processing? || export.pending? %>
-
-
- <%= t("family_exports.exporting") %>
-
- <% elsif export.completed? %>
-
- <%= button_to family_export_path(export),
- method: :delete,
- class: "flex items-center gap-2 text-destructive hover:text-destructive-hover",
- aria: { label: t("family_exports.table.row.actions.delete") },
- data: {
- turbo_confirm: t("family_exports.delete_confirmation"),
- turbo_frame: "_top"
- } do %>
- <%= icon "trash-2", class: "w-5 h-5 text-destructive" %>
- <% end %>
-
- <%= link_to download_family_export_path(export),
- class: "flex items-center gap-2 text-primary hover:text-primary-hover",
- aria: { label: t("family_exports.table.row.actions.download") },
- data: { turbo_frame: "_top" } do %>
- <%= icon "download", class: "w-5 h-5" %>
- <% end %>
-
- <% elsif export.failed? %>
-
-
- <% end %>
-
- <%= icon "alert-circle", class: "w-4 h-4" %>
-
-
- <%= button_to family_export_path(export),
- method: :delete,
- class: "flex items-center gap-2 text-destructive hover:text-destructive-hover",
- aria: { label: t("family_exports.table.row.actions.delete") },
- data: {
- turbo_confirm: t("family_exports.delete_failed_confirmation"),
- turbo_frame: "_top"
- } do %>
- <%= icon "trash-2", class: "w-5 h-5 text-destructive" %>
- <% end %>
- |
-
- <%= t("family_exports.table.empty") %> + <% has_processing = @exports.any? { |e| e.pending? || e.processing? } %> + <%= turbo_frame_tag "family_exports", + data: has_processing ? { + controller: "polling", + polling_url_value: family_exports_path, + polling_interval_value: 3000 + } : {} do %> +
+ <%= t("family_exports.table.title") %>
- <% end %> -<%= @pagy.count %>
+| + <%= t("family_exports.table.header.date") %> + | ++ <%= t("family_exports.table.header.filename") %> + | ++ <%= t("family_exports.table.header.status") %> + | ++ <%= t("family_exports.table.header.actions") %> + | +
|---|---|---|---|
| + + <%= l(export.created_at, format: :long) %> + + | ++ + <%= export.filename %> + + | ++ <% if export.processing? || export.pending? %> + <%= render "shared/badge" do %> + <%= t("family_exports.table.row.status.in_progress") %> + <% end %> + <% elsif export.completed? %> + <%= render "shared/badge", color: "success" do %> + <%= t("family_exports.table.row.status.complete") %> + <% end %> + <% elsif export.failed? %> + <%= render "shared/badge", color: "error" do %> + <%= t("family_exports.table.row.status.failed") %> + <% end %> + <% end %> + | +
+ <% if export.processing? || export.pending? %>
+
+
+ <%= t("family_exports.exporting") %>
+
+ <% elsif export.completed? %>
+
+ <%= button_to family_export_path(export),
+ method: :delete,
+ class: "flex items-center gap-2 text-destructive hover:text-destructive-hover",
+ aria: { label: t("family_exports.table.row.actions.delete") },
+ data: {
+ turbo_confirm: t("family_exports.delete_confirmation"),
+ turbo_frame: "_top"
+ } do %>
+ <%= icon "trash-2", class: "w-5 h-5 text-destructive" %>
+ <% end %>
+
+ <%= link_to download_family_export_path(export),
+ class: "flex items-center gap-2 text-primary hover:text-primary-hover",
+ aria: { label: t("family_exports.table.row.actions.download") },
+ data: { turbo_frame: "_top" } do %>
+ <%= icon "download", class: "w-5 h-5" %>
+ <% end %>
+
+ <% elsif export.failed? %>
+
+
+ <% end %>
+
+ <%= icon "alert-circle", class: "w-4 h-4" %>
+
+
+ <%= button_to family_export_path(export),
+ method: :delete,
+ class: "flex items-center gap-2 text-destructive hover:text-destructive-hover",
+ aria: { label: t("family_exports.table.row.actions.delete") },
+ data: {
+ turbo_confirm: t("family_exports.delete_failed_confirmation"),
+ turbo_frame: "_top"
+ } do %>
+ <%= icon "trash-2", class: "w-5 h-5 text-destructive" %>
+ <% end %>
+ |
+
+ <%= t("family_exports.table.empty") %> +
+ <% end %> +