Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions app/components/refresh_notice_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<div <%= tag.attributes(system_arguments_with_data) %>>
<%= helpers.turbo_stream_from streamable,
stream_name,
data: {
"refresh-target": "source",
} %>

<div
id="<%= alert_id %>"
data-refresh-target="notice"
role="alert"
aria-live="assertive"
aria-atomic="true"
class="
hidden fixed top-1/4 left-1/2 -translate-x-1/2 -translate-y-1/4 z-50 w-full
max-w-xl mx-auto px-4
"
>
<div
class="
flex items-center p-4 rounded-lg shadow-lg bg-white dark:bg-slate-800 border
border-slate-200 dark:border-slate-700
"
>
<!-- Icon -->
<div
class="
inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg
bg-blue-100 dark:bg-blue-900
"
>
<%= helpers.pathogen_icon(:info, color: :blue, size: :md) %>
</div>
<!-- Message -->
<div class="ml-3 text-sm font-medium text-slate-700 dark:text-slate-200 flex-1">
<%= message %>
</div>
<!-- Actions -->
<div class="flex items-center ml-auto pl-3 gap-3">
<!-- Refresh Button -->
<button
type="button"
data-action="click->refresh#refresh"
class="
px-3 py-1.5 text-sm font-medium text-primary-600 dark:text-primary-400
hover:text-primary-800 dark:hover:text-primary-300 transition-colors
duration-200 cursor-pointer
"
>
<%= link_text %>
</button>
<!-- Dismiss Button -->
<button
type="button"
data-action="click->refresh#dismiss"
class="
inline-flex items-center justify-center p-1 text-slate-400 hover:text-slate-600
dark:text-slate-500 dark:hover:text-slate-300 transition-colors duration-200
cursor-pointer
"
aria-label="<%= I18n.t('general.screen_reader.close') %>"
>
<%= helpers.pathogen_icon(:x, color: :subdued, size: :sm) %>
</button>
</div>
</div>
</div>
</div>
69 changes: 69 additions & 0 deletions app/components/refresh_notice_component.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion app/components/samples/table_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions app/javascript/controllers/editable_cell_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default class extends Controller {
"confirmDialogContainer",
"confirmDialogTemplate",
];
static outlets = ["refresh"];
#originalCellContent;

initialize() {
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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();
}
});
}
}
72 changes: 72 additions & 0 deletions app/javascript/controllers/refresh_controller.js
Original file line number Diff line number Diff line change
@@ -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("<turbo-stream") &&
event.data.includes('action="refresh"')
);
}
}
20 changes: 19 additions & 1 deletion app/models/sample.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class Sample < ApplicationRecord

belongs_to :project, counter_cache: true

broadcasts_refreshes_to :project
after_commit :broadcast_refresh_later_to_samples_table

has_many :attachments, as: :attachable, dependent: :destroy

Expand Down Expand Up @@ -68,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
6 changes: 6 additions & 0 deletions app/views/groups/samples/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@
<% end %>
<% end %>

<%= render RefreshNoticeComponent.new(
streamable: @group,
stream_name: :samples,
message: t(".refresh_notice.message"),
) %>

<% if @group_project_ids.count.positive? %>
<div class="flex flex-wrap gap-2 mb-4">
<% if @allowed_to[:submit_workflow] || @allowed_to[:export_data] %>
Expand Down
5 changes: 3 additions & 2 deletions app/views/projects/samples/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@
<%= turbo_frame_tag "selected" %>
<%= turbo_frame_tag "file_selector_dialog" %>

<%= turbo_stream_from @project %>

<div class="fixed-table-component">

<%= render Viral::PageHeaderComponent.new(title: t('.title')) do |component| %>
<% component.with_buttons do %>
<% if @allowed_to[:submit_workflow] && @has_samples && @pipelines_enabled %>
Expand Down Expand Up @@ -164,6 +163,8 @@
<% end %>
<% end %>

<%= render RefreshNoticeComponent.new(streamable: @project, stream_name: :samples, message: t(".refresh_notice.message")) %>

<% if @has_samples %>
<div class="flex flex-wrap gap-2 mb-4">
<% if @allowed_to[:submit_workflow] || @allowed_to[:clone_sample] || @allowed_to[:transfer_sample] || @allowed_to[:export_data] %>
Expand Down
8 changes: 8 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions config/locales/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading