diff --git a/app/components/refresh_notice_component.html.erb b/app/components/refresh_notice_component.html.erb new file mode 100644 index 0000000000..68189ebfbc --- /dev/null +++ b/app/components/refresh_notice_component.html.erb @@ -0,0 +1,68 @@ +
> + <%= 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/components/samples/table_component.rb b/app/components/samples/table_component.rb index a4b50bc767..f607739c80 100644 --- a/app/components/samples/table_component.rb +++ b/app/components/samples/table_component.rb @@ -72,7 +72,8 @@ def wrapper_arguments { tag: 'div', classes: class_names('table-container @2xl:flex @2xl:flex-col @3xl:shrink @3xl:min-h-0'), - 'data-controller' => 'editable-cell' + 'data-controller' => 'editable-cell', + 'data-editable-cell-refresh-outlet' => "[data-controller='refresh']" } end diff --git a/app/javascript/controllers/editable_cell_controller.js b/app/javascript/controllers/editable_cell_controller.js index e1dacc17d3..989e352767 100644 --- a/app/javascript/controllers/editable_cell_controller.js +++ b/app/javascript/controllers/editable_cell_controller.js @@ -8,6 +8,7 @@ export default class extends Controller { "confirmDialogContainer", "confirmDialogTemplate", ]; + static outlets = ["refresh"]; #originalCellContent; initialize() { @@ -54,6 +55,8 @@ export default class extends Controller { return; } + this.#notifyRefreshControllers(); + let form = this.formTemplateTarget.innerHTML .replace(/SAMPLE_ID_PLACEHOLDER/g, item_id) .replace(/FIELD_ID_PLACEHOLDER/g, encodeURIComponent(field)) @@ -154,4 +157,14 @@ export default class extends Controller { .dataset.fieldId.replaceAll(" ", "SPACE"); return `${field}_${element.parentNode.rowIndex}`; } + + #notifyRefreshControllers() { + if (!this.hasRefreshOutlet) return; + + this.refreshOutlets.forEach((outlet) => { + if (outlet && typeof outlet.ignoreNextRefresh === "function") { + outlet.ignoreNextRefresh(); + } + }); + } } diff --git a/app/javascript/controllers/refresh_controller.js b/app/javascript/controllers/refresh_controller.js new file mode 100644 index 0000000000..dc1af0c5af --- /dev/null +++ b/app/javascript/controllers/refresh_controller.js @@ -0,0 +1,72 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["notice", "source"]; + #ignoreNextRefresh; + #ignoreTimeoutId; + + initialize() { + this.boundMessageHandler = this.messageHandler.bind(this); + this.#ignoreNextRefresh = false; + this.#ignoreTimeoutId = null; + } + + sourceTargetConnected(element) { + element.addEventListener("message", this.boundMessageHandler, true); + } + + sourceTargetDisconnected(element) { + element.removeEventListener("message", this.boundMessageHandler, true); + this.#clearIgnoreTimeout(); + } + + messageHandler(event) { + if (!this.#isRefreshTurboStream(event)) return; + + if (this.#ignoreNextRefresh) { + this.#ignoreNextRefresh = false; + this.#clearIgnoreTimeout(); + event.stopImmediatePropagation(); + return; + } + + if (this.hasNoticeTarget) { + this.noticeTarget.classList.remove("hidden"); + } + + event.stopImmediatePropagation(); + } + + dismiss() { + this.noticeTarget.classList.add("hidden"); + } + + refresh() { + // Reload the current page + window.location.reload(); + } + + ignoreNextRefresh() { + this.#ignoreNextRefresh = true; + this.#clearIgnoreTimeout(); + this.#ignoreTimeoutId = window.setTimeout(() => { + this.#ignoreNextRefresh = false; + this.#ignoreTimeoutId = null; + }, 5000); + } + + #clearIgnoreTimeout() { + if (this.#ignoreTimeoutId) { + clearTimeout(this.#ignoreTimeoutId); + this.#ignoreTimeoutId = null; + } + } + + #isRefreshTurboStream(event) { + return ( + typeof event.data === "string" && + event.data.startsWith(" <% end %> + <%= render RefreshNoticeComponent.new( + streamable: @group, + stream_name: :samples, + message: t(".refresh_notice.message"), + ) %> + <% if @group_project_ids.count.positive? %>
<% if @allowed_to[:submit_workflow] || @allowed_to[:export_data] %> diff --git a/app/views/projects/samples/index.html.erb b/app/views/projects/samples/index.html.erb index 5aa930ff74..37667e54f4 100644 --- a/app/views/projects/samples/index.html.erb +++ b/app/views/projects/samples/index.html.erb @@ -5,9 +5,8 @@ <%= turbo_frame_tag "selected" %> <%= turbo_frame_tag "file_selector_dialog" %> -<%= turbo_stream_from @project %> -
+ <%= render Viral::PageHeaderComponent.new(title: t('.title')) do |component| %> <% component.with_buttons do %> <% if @allowed_to[:submit_workflow] && @has_samples && @pipelines_enabled %> @@ -164,6 +163,8 @@ <% end %> <% end %> + <%= render RefreshNoticeComponent.new(streamable: @project, stream_name: :samples, message: t(".refresh_notice.message")) %> + <% if @has_samples %>
<% if @allowed_to[:submit_workflow] || @allowed_to[:clone_sample] || @allowed_to[:transfer_sample] || @allowed_to[:export_data] %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 11505b31a6..053b13ef0a 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: @@ -886,6 +890,8 @@ en: index: deselect_all_button: Deselect All deselect_all_tooltip: Deselect all samples in this group + 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 group subtitle: These are the samples in %{namespace_type} %{namespace_name} @@ -1562,6 +1568,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..a894e9d28a 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: @@ -888,6 +892,8 @@ fr: index: deselect_all_button: Désélectionner tout deselect_all_tooltip: Désélectionner tout + 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: Tout sélectionner subtitle: Voici les échantillons dans %{namespace_type} %{namespace_name} @@ -1566,6 +1572,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