From cec1beb4cf4cf6fc06003188f8f391e2963c3349 Mon Sep 17 00:00:00 2001 From: Eric Enns <492127+ericenns@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:00:04 +0000 Subject: [PATCH 1/8] feat: add in refresh controller that causes refresh notice to be displayed when using turbo_stream_from --- .../controllers/refresh_controller.js | 27 +++++++++++++++++++ app/views/projects/samples/index.html.erb | 10 ++++--- 2 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 app/javascript/controllers/refresh_controller.js diff --git a/app/javascript/controllers/refresh_controller.js b/app/javascript/controllers/refresh_controller.js new file mode 100644 index 0000000000..f9e9f21936 --- /dev/null +++ b/app/javascript/controllers/refresh_controller.js @@ -0,0 +1,27 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["notice", "source"]; + + initialize() { + this.boundMessageHandler = this.messageHandler.bind(this); + } + + sourceTargetConnected(element) { + element.addEventListener("message", this.boundMessageHandler, true); + } + + sourceTargetDisconnected(element) { + element.removeEventListener("message", this.boundMessageHandler, true); + } + + messageHandler(event) { + if ( + typeof event.data === "string" && + event.data === '' + ) { + this.noticeTarget.classList.remove("hidden"); + event.stopImmediatePropagation(); + } + } +} diff --git a/app/views/projects/samples/index.html.erb b/app/views/projects/samples/index.html.erb index 5aa930ff74..8376723f57 100644 --- a/app/views/projects/samples/index.html.erb +++ b/app/views/projects/samples/index.html.erb @@ -5,9 +5,13 @@ <%= turbo_frame_tag "selected" %> <%= turbo_frame_tag "file_selector_dialog" %> -<%= turbo_stream_from @project %> - -
+
+ <%= turbo_stream_from @project, data: { "refresh-target": "source" } %> + <%= viral_alert(message: "Page has updated", data: { "refresh-target": "notice" }, classes: "hidden", aria: { + live: "assertive", + }) do %> + <%= link_to "refresh" %> + <% end %> <%= render Viral::PageHeaderComponent.new(title: t('.title')) do |component| %> <% component.with_buttons do %> <% if @allowed_to[:submit_workflow] && @has_samples && @pipelines_enabled %> From 895f439d42ffffa7a6f3ee852aa115fa11a7c978 Mon Sep 17 00:00:00 2001 From: Eric Enns <492127+ericenns@users.noreply.github.com> Date: Thu, 11 Sep 2025 18:27:45 +0000 Subject: [PATCH 2/8] chore: update sample model to broadcast refreshes to group hierarchy --- app/models/sample.rb | 12 +++++++++++- app/views/groups/samples/index.html.erb | 11 +++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/models/sample.rb b/app/models/sample.rb index 57e27941de..c141d0fb05 100644 --- a/app/models/sample.rb +++ b/app/models/sample.rb @@ -12,7 +12,17 @@ class Sample < ApplicationRecord belongs_to :project, counter_cache: true - broadcasts_refreshes_to :project + after_commit do + projects = [project] + projects << Project.find(previous_changes['project_id'][0]) if previous_changes.key? 'project_id' + + projects.each do |project| + broadcast_refresh_later_to project, :samples + project.namespace.parent.self_and_ancestors.each do |ancestor| + broadcast_refresh_later_to ancestor, :samples + end + end + end has_many :attachments, as: :attachable, dependent: :destroy diff --git a/app/views/groups/samples/index.html.erb b/app/views/groups/samples/index.html.erb index 69fbb30969..eb271f4ab6 100644 --- a/app/views/groups/samples/index.html.erb +++ b/app/views/groups/samples/index.html.erb @@ -149,6 +149,17 @@ <% end %> <% end %> +
+ <%= turbo_stream_from @group, :samples, data: { "refresh-target": "source" } %> + <%= viral_alert(message: "Samples table is out of date. Please refresh to view latest changes.", data: { "refresh-target": "notice" }, classes: "hidden mb-6", aria: { + live: "assertive", + }) do %> + <%= pathogen_button(tag: :a, href: url_for) do %> + Refresh + <% end %> + <% end %> +
+ <% if @group_project_ids.count.positive? %>
<% if @allowed_to[:submit_workflow] || @allowed_to[:export_data] %> From 01818e01c857ba0eb45823fa862f46690eed514a Mon Sep 17 00:00:00 2001 From: Eric Enns <492127+ericenns@users.noreply.github.com> Date: Tue, 16 Sep 2025 19:46:08 +0000 Subject: [PATCH 3/8] chore: fix after_commit hook in sample to only include previous project if the sample was transferred not on creation --- app/models/sample.rb | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/models/sample.rb b/app/models/sample.rb index c141d0fb05..13d9f63088 100644 --- a/app/models/sample.rb +++ b/app/models/sample.rb @@ -13,13 +13,15 @@ class Sample < ApplicationRecord belongs_to :project, counter_cache: true after_commit do - projects = [project] - projects << Project.find(previous_changes['project_id'][0]) if previous_changes.key? 'project_id' - - projects.each do |project| - broadcast_refresh_later_to project, :samples - project.namespace.parent.self_and_ancestors.each do |ancestor| - broadcast_refresh_later_to ancestor, :samples + unless Sample.suppressed_turbo_broadcasts + projects = [project] + projects << Project.find(previous_changes['project_id'][0]) if previous_changes['project_id'] && !previous_changes['project_id'][0].nil? + + projects.each do |project| + broadcast_refresh_later_to project, :samples + project.namespace.parent.self_and_ancestors.each do |ancestor| + broadcast_refresh_later_to ancestor, :samples + end end end end From 780cb596bd52f026f51cb2639d8da22bd6a6c6f7 Mon Sep 17 00:00:00 2001 From: Eric Enns <492127+ericenns@users.noreply.github.com> Date: Wed, 17 Sep 2025 13:25:43 +0000 Subject: [PATCH 4/8] chore: remove extra aria-live attribute --- app/views/groups/samples/index.html.erb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/views/groups/samples/index.html.erb b/app/views/groups/samples/index.html.erb index eb271f4ab6..c0ab098608 100644 --- a/app/views/groups/samples/index.html.erb +++ b/app/views/groups/samples/index.html.erb @@ -151,9 +151,7 @@
<%= turbo_stream_from @group, :samples, data: { "refresh-target": "source" } %> - <%= viral_alert(message: "Samples table is out of date. Please refresh to view latest changes.", data: { "refresh-target": "notice" }, classes: "hidden mb-6", aria: { - live: "assertive", - }) do %> + <%= viral_alert(message: "Samples table is out of date. Please refresh to view latest changes.", data: { "refresh-target": "notice" }, classes: "hidden mb-6") do %> <%= pathogen_button(tag: :a, href: url_for) do %> Refresh <% end %> From affe0b2cc16359467fbd4f2865160b3d7fb12aca Mon Sep 17 00:00:00 2001 From: Eric Enns <492127+ericenns@users.noreply.github.com> Date: Wed, 17 Sep 2025 13:50:08 +0000 Subject: [PATCH 5/8] chore: refactor after_commit in samples model --- app/models/sample.rb | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/app/models/sample.rb b/app/models/sample.rb index 13d9f63088..281e5d4178 100644 --- a/app/models/sample.rb +++ b/app/models/sample.rb @@ -12,19 +12,7 @@ class Sample < ApplicationRecord belongs_to :project, counter_cache: true - after_commit do - unless Sample.suppressed_turbo_broadcasts - projects = [project] - projects << Project.find(previous_changes['project_id'][0]) if previous_changes['project_id'] && !previous_changes['project_id'][0].nil? - - projects.each do |project| - broadcast_refresh_later_to project, :samples - project.namespace.parent.self_and_ancestors.each do |ancestor| - broadcast_refresh_later_to ancestor, :samples - end - end - end - end + after_commit :broadcast_refresh_later_to_samples_table has_many :attachments, as: :attachable, dependent: :destroy @@ -80,4 +68,22 @@ def updatable_field?(field) metadata_provenance[field]['source'] == 'user' end + + private + + def broadcast_refresh_later_to_samples_table # rubocop:disable Metrics/AbcSize + return if Sample.suppressed_turbo_broadcasts + + projects = [project] + if previous_changes['project_id'] && !previous_changes['project_id'][0].nil? + projects << Project.find(previous_changes['project_id'][0]) + end + + projects.each do |project| + broadcast_refresh_later_to project, :samples + project.namespace.parent.self_and_ancestors.each do |ancestor| + broadcast_refresh_later_to ancestor, :samples + end + end + end end From 0eb051f5682a21fd22d51003ef134a141e121fbf Mon Sep 17 00:00:00 2001 From: Josh Adam Date: Wed, 15 Oct 2025 13:29:55 -0500 Subject: [PATCH 6/8] Refactors refresh controller logic Refactors the refresh controller to handle turbo stream messages more robustly by checking for the presence of `action="refresh"` and ensures the event data starts with " --- .../controllers/refresh_controller.js | 3 ++- app/views/projects/samples/index.html.erb | 18 +++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/app/javascript/controllers/refresh_controller.js b/app/javascript/controllers/refresh_controller.js index f9e9f21936..c0c2e19974 100644 --- a/app/javascript/controllers/refresh_controller.js +++ b/app/javascript/controllers/refresh_controller.js @@ -18,7 +18,8 @@ export default class extends Controller { messageHandler(event) { if ( typeof event.data === "string" && - event.data === '' + event.data.startsWith(" <%= turbo_frame_tag "file_selector_dialog" %> -
- <%= turbo_stream_from @project, data: { "refresh-target": "source" } %> - <%= viral_alert(message: "Page has updated", data: { "refresh-target": "notice" }, classes: "hidden", aria: { - live: "assertive", - }) do %> - <%= link_to "refresh" %> - <% end %> +
+ <%= render Viral::PageHeaderComponent.new(title: t('.title')) do |component| %> <% component.with_buttons do %> <% if @allowed_to[:submit_workflow] && @has_samples && @pipelines_enabled %> @@ -168,6 +163,15 @@ <% end %> <% end %> +
+ <%= turbo_stream_from @project, :samples, data: { "refresh-target": "source" } %> + <%= viral_alert(message: "Page has updated", data: { "refresh-target": "notice" }, classes: "hidden", aria: { + live: "assertive", + }) do %> + <%= link_to "refresh" %> + <% end %> +
+ <% if @has_samples %>
<% if @allowed_to[:submit_workflow] || @allowed_to[:clone_sample] || @allowed_to[:transfer_sample] || @allowed_to[:export_data] %> From 8fd9735db79a50095ef36ad3172f3abfb1306f54 Mon Sep 17 00:00:00 2001 From: Josh Adam Date: Wed, 15 Oct 2025 21:48:07 -0500 Subject: [PATCH 7/8] Enhance refresh functionality and localization support Adds a dismiss method to the refresh controller and a refresh method to reload the page. Updates the samples index view to use the new RefreshNoticeComponent for displaying refresh notifications. Localizes refresh notice messages in English and French. --- .../refresh_notice_component.html.erb | 63 ++++++++ app/components/refresh_notice_component.rb | 69 ++++++++ .../controllers/refresh_controller.js | 9 ++ app/views/projects/samples/index.html.erb | 9 +- config/locales/en.yml | 6 + config/locales/fr.yml | 6 + .../refresh_notice_component_test.rb | 151 ++++++++++++++++++ 7 files changed, 305 insertions(+), 8 deletions(-) create mode 100644 app/components/refresh_notice_component.html.erb create mode 100644 app/components/refresh_notice_component.rb create mode 100644 test/components/refresh_notice_component_test.rb diff --git a/app/components/refresh_notice_component.html.erb b/app/components/refresh_notice_component.html.erb new file mode 100644 index 0000000000..eff96e630f --- /dev/null +++ b/app/components/refresh_notice_component.html.erb @@ -0,0 +1,63 @@ +
> + <%= helpers.turbo_stream_from streamable, stream_name, data: { "refresh-target": "source" } %> + + + +
diff --git a/app/components/refresh_notice_component.rb b/app/components/refresh_notice_component.rb new file mode 100644 index 0000000000..0fd44955ad --- /dev/null +++ b/app/components/refresh_notice_component.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +# ViewComponent for refresh notifications with Turbo Streams. +# +# Responsibilities +# - Render a hidden alert that shows when a page has been updated via Turbo Streams +# - Integrate with the "refresh" Stimulus controller +# - Provide a link to refresh the page content +# +# Usage +# <%= render RefreshNoticeComponent.new( +# streamable: @project, +# stream_name: :samples +# ) %> +# +# # With custom messages +# <%= render RefreshNoticeComponent.new( +# streamable: @project, +# stream_name: :samples, +# message: "New data available", +# link_text: "Load new data" +# ) %> +# +# Notes +# - The component uses the "refresh" Stimulus controller to manage visibility +# - The alert is initially hidden and shown when Turbo Stream updates are received +class RefreshNoticeComponent < Component + # @return [Object] The streamable object (e.g., @project) + attr_reader :streamable + # @return [Symbol, String] The stream name (e.g., :samples) + attr_reader :stream_name + # @return [String] The message to display in the alert + attr_reader :message + # @return [String] The text for the refresh link + attr_reader :link_text + + # Initialize the RefreshNotice component. + # + # @param streamable [Object] The object to establish a Turbo Stream connection from + # @param stream_name [Symbol, String] The name of the stream (e.g., :samples, :members) + # @param message [String] The message to display when an update is available + # @param link_text [String] The text for the refresh link + # @param system_arguments [Hash] Additional HTML attributes (classes are merged) + def initialize(streamable:, stream_name:, message: nil, link_text: nil, **system_arguments) + super() + @streamable = streamable + @stream_name = stream_name + @message = message || I18n.t('components.refresh_notice.default_message') + @link_text = link_text || I18n.t('components.refresh_notice.default_link_text') + @system_arguments = system_arguments + @system_arguments[:data] ||= {} + @system_arguments[:data][:controller] = 'refresh' + @system_arguments[:data][:action] ||= '' + end + + # Compose safe HTML attributes for the outer wrapper. + # + # @return [Hash] merged attributes suitable for tag helpers + def system_arguments_with_data + @system_arguments + end + + # Unique ID for the alert + # + # @return [String] + def alert_id + @alert_id ||= "refresh-notice-#{object_id}" + end +end diff --git a/app/javascript/controllers/refresh_controller.js b/app/javascript/controllers/refresh_controller.js index c0c2e19974..5ee33feb4c 100644 --- a/app/javascript/controllers/refresh_controller.js +++ b/app/javascript/controllers/refresh_controller.js @@ -25,4 +25,13 @@ export default class extends Controller { event.stopImmediatePropagation(); } } + + dismiss() { + this.noticeTarget.classList.add("hidden"); + } + + refresh() { + // Reload the current page + window.location.reload(); + } } diff --git a/app/views/projects/samples/index.html.erb b/app/views/projects/samples/index.html.erb index 8044070e76..37667e54f4 100644 --- a/app/views/projects/samples/index.html.erb +++ b/app/views/projects/samples/index.html.erb @@ -163,14 +163,7 @@ <% end %> <% end %> -
- <%= turbo_stream_from @project, :samples, data: { "refresh-target": "source" } %> - <%= viral_alert(message: "Page has updated", data: { "refresh-target": "notice" }, classes: "hidden", aria: { - live: "assertive", - }) do %> - <%= link_to "refresh" %> - <% end %> -
+ <%= render RefreshNoticeComponent.new(streamable: @project, stream_name: :samples, message: t(".refresh_notice.message")) %> <% if @has_samples %>
diff --git a/config/locales/en.yml b/config/locales/en.yml index 11505b31a6..ebde27068c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -320,6 +320,10 @@ en: expiration: Expiration username: Username never: Never + components: + refresh_notice: + default_link_text: Refresh + default_message: New data available concerns: attachment_actions: destroy: @@ -1562,6 +1566,8 @@ en: deselect_all_tooltip: Deselect all samples in this project no_associated_samples: There are no samples associated with this project. no_samples: No Samples + refresh_notice: + message: Samples table is out of date. Please refresh to view latest changes. select_all_button: Select All select_all_tooltip: Select all samples in this project title: Samples diff --git a/config/locales/fr.yml b/config/locales/fr.yml index c88a355ec7..cc3a969b56 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -320,6 +320,10 @@ fr: expiration: Expiration username: Nom d’utilisateur never: Jamais + components: + refresh_notice: + default_link_text: Actualiser + default_message: Nouvelles données disponibles concerns: attachment_actions: destroy: @@ -1566,6 +1570,8 @@ fr: deselect_all_tooltip: Désélectionner tous les échantillons de ce projet no_associated_samples: Il n’y a aucun échantillon associé à ce projet. no_samples: Aucun échantillon + refresh_notice: + message: Le tableau des échantillons est obsolète. Veuillez actualiser pour voir les dernières modifications. select_all_button: Tout sélectionner select_all_tooltip: Sélectionner tous les échantillons de ce projet title: Échantillons diff --git a/test/components/refresh_notice_component_test.rb b/test/components/refresh_notice_component_test.rb new file mode 100644 index 0000000000..c44884a20b --- /dev/null +++ b/test/components/refresh_notice_component_test.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require 'view_component_test_case' + +class RefreshNoticeComponentTest < ViewComponentTestCase + def setup + @project = namespaces_project_namespaces(:project1) + end + + # 🔄 BASIC RENDERING - Ensure component renders with required elements + + test 'renders with refresh controller and turbo stream' do + render_inline(RefreshNoticeComponent.new(streamable: @project, stream_name: :samples)) + + assert_selector '[data-controller="refresh"]', count: 1 + assert_selector 'turbo-cable-stream-source', count: 1 + assert_selector '[data-refresh-target="source"]', count: 1 + end + + test 'renders with viral alert component' do + render_inline(RefreshNoticeComponent.new(streamable: @project, stream_name: :samples)) + + assert_selector '[data-refresh-target="notice"]', count: 1 + assert_selector '.hidden', count: 1 + assert_selector '[aria-live="assertive"]', count: 1 + end + + test 'uses default message when not provided' do + render_inline(RefreshNoticeComponent.new(streamable: @project, stream_name: :samples)) + + assert_text I18n.t('components.refresh_notice.default_message') + end + + test 'uses default link text when not provided' do + render_inline(RefreshNoticeComponent.new(streamable: @project, stream_name: :samples)) + + assert_text I18n.t('components.refresh_notice.default_link_text') + end + + # 🎯 CUSTOM CONTENT - Test custom messages and links + + test 'renders with custom message' do + custom_message = 'New data available' + render_inline(RefreshNoticeComponent.new( + streamable: @project, + stream_name: :samples, + message: custom_message + )) + + assert_text custom_message + end + + test 'renders with custom link text' do + custom_link_text = 'Load new data' + render_inline(RefreshNoticeComponent.new( + streamable: @project, + stream_name: :samples, + link_text: custom_link_text + )) + + assert_text custom_link_text + end + + test 'renders with both custom message and link text' do + custom_message = 'Updates are ready' + custom_link_text = 'Reload page' + render_inline(RefreshNoticeComponent.new( + streamable: @project, + stream_name: :samples, + message: custom_message, + link_text: custom_link_text + )) + + assert_text custom_message + assert_text custom_link_text + end + + # 🔌 TURBO STREAM CONFIGURATION - Test stream setup + + test 'configures turbo stream with correct streamable and stream name' do + render_inline(RefreshNoticeComponent.new(streamable: @project, stream_name: :samples)) + + turbo_source = page.find('turbo-cable-stream-source') + # The channel attribute should include the project and stream name + assert turbo_source['channel'].present? + end + + test 'works with different stream names' do + %i[samples members attachments].each do |stream_name| + render_inline(RefreshNoticeComponent.new(streamable: @project, stream_name: stream_name)) + + assert_selector '[data-controller="refresh"]', count: 1 + assert_selector 'turbo-cable-stream-source', count: 1 + end + end + + # 🎨 SYSTEM ARGUMENTS - Test custom styling and attributes + + test 'accepts and applies additional system arguments' do + render_inline(RefreshNoticeComponent.new( + streamable: @project, + stream_name: :samples, + class: 'custom-class', + id: 'custom-id' + )) + + assert_selector '#custom-id', count: 1 + assert_selector '.custom-class', count: 1 + assert_selector '[data-controller="refresh"]', count: 1 + end + + # 🔧 ACCESSIBILITY - Test accessibility attributes + + test 'alert has proper accessibility attributes' do + render_inline(RefreshNoticeComponent.new(streamable: @project, stream_name: :samples)) + + assert_selector '[aria-live="assertive"]', count: 1 + end + + test 'alert is initially hidden' do + render_inline(RefreshNoticeComponent.new(streamable: @project, stream_name: :samples)) + + # The alert should have the hidden class initially + assert_selector '.hidden', count: 1 + end + + # 🧪 INTEGRATION - Test component structure + + test 'complete component structure is correct' do + render_inline(RefreshNoticeComponent.new( + streamable: @project, + stream_name: :samples, + message: 'Test message', + link_text: 'Test link' + )) + + # Wrapper with refresh controller + assert_selector '[data-controller="refresh"]', count: 1 + + # Turbo stream source + assert_selector 'turbo-cable-stream-source[data-refresh-target="source"]', count: 1 + + # Alert notice + assert_selector '[data-refresh-target="notice"]', count: 1 + assert_selector '[aria-live="assertive"]', count: 1 + + # Content + assert_text 'Test message' + assert_text 'Test link' + end +end From 35d8a15e33424915fc6dbcfa662d65a671151170 Mon Sep 17 00:00:00 2001 From: Josh Adam Date: Thu, 16 Oct 2025 14:00:07 -0500 Subject: [PATCH 8/8] Adds refresh notice component Implements a refresh notice component to inform users when data is out of date and provide a button to refresh. The component utilizes Turbo Streams for real-time updates and includes logic to prevent redundant refreshes triggered by editable cells. It also uses a stimulus controller to handle dismissing the notice and reloading the page. The component is now rendered in the samples index view. --- .../refresh_notice_component.html.erb | 49 ++++++++++--------- app/components/samples/table_component.rb | 3 +- .../controllers/editable_cell_controller.js | 13 +++++ .../controllers/refresh_controller.js | 47 +++++++++++++++--- app/views/groups/samples/index.html.erb | 13 ++--- config/locales/en.yml | 2 + config/locales/fr.yml | 2 + 7 files changed, 92 insertions(+), 37 deletions(-) diff --git a/app/components/refresh_notice_component.html.erb b/app/components/refresh_notice_component.html.erb index eff96e630f..68189ebfbc 100644 --- a/app/components/refresh_notice_component.html.erb +++ b/app/components/refresh_notice_component.html.erb @@ -1,34 +1,40 @@
> - <%= helpers.turbo_stream_from streamable, stream_name, data: { "refresh-target": "source" } %> + <%= helpers.turbo_stream_from streamable, + stream_name, + data: { + "refresh-target": "source", + } %> -