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 %>

-
- -
- <% if @exports.any? %> - - - - - - - - - - - <% @exports.ordered.each do |export| %> - - - - - - - <% end %> - -
- <%= 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? %> -
-
- <%= 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 %> -
- <% end %> -
- <% else %> -

- <%= 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 %>

+
- <% if @pagy.pages > 1 %> -
- <%= render "shared/pagination", pagy: @pagy %> +
+ <% if @exports.any? %> + + + + + + + + + + + <% @exports.ordered.each do |export| %> + + + + + + + <% end %> + +
+ <%= 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? %> +
+
+ <%= 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 %> +
+ <% end %> +
+ <% else %> +

+ <%= t("family_exports.table.empty") %> +

+ <% end %> +
+ + <% if @pagy.pages > 1 %> +
+ <%= render "shared/pagination", pagy: @pagy %> +
+ <% end %> <% end %> <%= link_to new_family_export_path,