From eed229c8707ef900de6a22b07f9689d1f765ff94 Mon Sep 17 00:00:00 2001 From: Sebastian Serth Date: Thu, 26 Jun 2025 09:59:04 +0200 Subject: [PATCH 01/14] Migrate Turbolinks to Turbo This is the first part of using Turbo for this code base. Some solutions, like the temporary events triggered, should be temporary and removed once we fully migrated from Sprockets to another JavaScript distribution (such as Shakapacker, importmaps, etc.) --- Gemfile | 2 +- Gemfile.lock | 7 +--- app/assets/javascripts/account_links.coffee | 2 +- app/assets/javascripts/application.js | 6 +++- app/assets/javascripts/base.js | 4 +-- app/assets/javascripts/collections.coffee | 2 +- app/assets/javascripts/editor.coffee | 2 +- app/assets/javascripts/flash_message.coffee | 2 +- app/assets/javascripts/groups.coffee | 2 +- app/assets/javascripts/labels_index.coffee | 2 +- app/assets/javascripts/markdown_editor.js | 2 +- app/assets/javascripts/nbp_wallet.js | 4 +-- app/assets/javascripts/task_files.coffee | 2 +- app/assets/javascripts/tasks_base.coffee | 2 +- app/assets/javascripts/tasks_checklist.coffee | 2 +- app/assets/javascripts/tasks_export.coffee | 2 +- app/assets/javascripts/tasks_form.coffee | 2 +- app/assets/javascripts/tasks_import.coffee | 2 +- app/assets/javascripts/tasks_index.coffee | 2 +- app/assets/javascripts/tasks_ratings.coffee | 2 +- app/assets/javascripts/tasks_show.coffee | 2 +- app/assets/javascripts/users.coffee | 2 +- app/javascript/application.js | 10 ++++-- app/javascript/turbo-migration.js | 35 +++++++++++++++++++ .../application/_locale_selector.html.slim | 2 +- app/views/application/_navigation.html.slim | 2 +- app/views/collections/_form.html.slim | 4 +-- app/views/layouts/application.html.slim | 8 ++--- app/views/tasks/_editor.html.slim | 2 +- app/views/tasks/_form.html.slim | 4 +-- app/views/tasks/show.html.slim | 10 +++--- .../initializers/content_security_policy.rb | 2 +- package.json | 1 + spec/support/wait_for_ajax.rb | 9 ++++- yarn.lock | 25 +++++++++++++ 35 files changed, 121 insertions(+), 50 deletions(-) create mode 100644 app/javascript/turbo-migration.js diff --git a/Gemfile b/Gemfile index 014274b8f..3da54f1ad 100644 --- a/Gemfile +++ b/Gemfile @@ -46,7 +46,7 @@ gem 'solid_cable' gem 'solid_queue' gem 'sprockets-rails' gem 'terser', require: false -gem 'turbolinks' +gem 'turbo-rails' # Authentication gem 'devise', '~> 4.9' diff --git a/Gemfile.lock b/Gemfile.lock index 1054a4e5f..cd317d223 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -608,9 +608,6 @@ GEM turbo-rails (2.0.13) actionpack (>= 7.1.0) railties (>= 7.1.0) - turbolinks (5.2.1) - turbolinks-source (~> 5.2) - turbolinks-source (5.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.1.4) @@ -731,7 +728,7 @@ DEPENDENCIES sprockets-rails stackprof terser - turbolinks + turbo-rails web-console webmock @@ -969,8 +966,6 @@ CHECKSUMS tilt (2.6.0) sha256=263d748466e0d83e510aa1a2e2281eff547937f0ef06be33d3632721e255f76b timeout (0.4.3) sha256=9509f079b2b55fe4236d79633bd75e34c1c1e7e3fb4b56cb5fda61f80a0fe30e turbo-rails (2.0.13) sha256=c40ac0a3ccd57c129925c8ac524a5dfd1e17fad080906e2d32135721a8bba22f - turbolinks (5.2.1) sha256=5fea5889c4e2a78a5bd9abda3860c565342b50c6e2593697d5558a08e15cce9c - turbolinks-source (5.2.0) sha256=362a41fa851a22b0f15cf8f944b6c7c5788f645dc1f61ae25478bb25c3bc85d4 tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b unicode-display_width (3.1.4) sha256=8caf2af1c0f2f07ec89ef9e18c7d88c2790e217c482bfc78aaa65eadd5415ac1 unicode-emoji (4.0.4) sha256=2c2c4ef7f353e5809497126285a50b23056cc6e61b64433764a35eff6c36532a diff --git a/app/assets/javascripts/account_links.coffee b/app/assets/javascripts/account_links.coffee index b8259b589..855770a97 100644 --- a/app/assets/javascripts/account_links.coffee +++ b/app/assets/javascripts/account_links.coffee @@ -9,4 +9,4 @@ ready = -> $row_value.parents('.row').remove() -$(document).on('turbolinks:load', ready) +$(document).on('turbo-migration:load', ready) diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 95813dc20..9cbcee2a3 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -10,8 +10,12 @@ // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details // about supported directives. // -//= require turbolinks //= require nested_form_fields // // app/assets //= require_tree . +// +// Finally, we dispatch a custom event to signal that all assets are loaded. +// This is used by our custom migration for Turbo to trigger the `turbo-migration:load` event +const sprocketsLoad = new Event('sprockets:load'); +document.dispatchEvent(sprocketsLoad); diff --git a/app/assets/javascripts/base.js b/app/assets/javascripts/base.js index c94e7f238..2b25745da 100644 --- a/app/assets/javascripts/base.js +++ b/app/assets/javascripts/base.js @@ -18,7 +18,7 @@ $.fn.scrollTo = function (selector) { }, ANIMATION_DURATION); }; -$(document).on('turbolinks:load', function () { +$(document).on('turbo-migration:load', function () { // Update all CSRF tokens on the page to reduce InvalidAuthenticityToken errors // See https://github.com/rails/jquery-ujs/issues/456 for details $.rails.refreshCSRFTokens(); @@ -34,7 +34,7 @@ $(document).on('turbolinks:load', function () { // Initialize Sentry const sentrySettings = $('meta[name="sentry"]') - // Workaround for Turbolinks: We must not re-initialize the Relay object when visiting another page + // Workaround for Turbo: We must not re-initialize the Relay object when visiting another page if (sentrySettings && sentrySettings.data()['enabled'] && Sentry.getReplay() === undefined) { Sentry.init({ dsn: sentrySettings.data('dsn'), diff --git a/app/assets/javascripts/collections.coffee b/app/assets/javascripts/collections.coffee index 54d3df6a1..41d1bb4c3 100644 --- a/app/assets/javascripts/collections.coffee +++ b/app/assets/javascripts/collections.coffee @@ -13,4 +13,4 @@ update_collections_tasks_order = (e) -> $(input_element).val(index); -$(document).on('turbolinks:load', ready) +$(document).on('turbo-migration:load', ready) diff --git a/app/assets/javascripts/editor.coffee b/app/assets/javascripts/editor.coffee index 82eaff307..58a353174 100644 --- a/app/assets/javascripts/editor.coffee +++ b/app/assets/javascripts/editor.coffee @@ -34,4 +34,4 @@ root.getModeByFileExtension = getModeByFileExtension root.changeEditorMode = changeEditorMode root.setAceEditorValue = setAceEditorValue -$(document).on('turbolinks:load', ready) +$(document).on('turbo-migration:load', ready) diff --git a/app/assets/javascripts/flash_message.coffee b/app/assets/javascripts/flash_message.coffee index 26083d338..d37a5de19 100644 --- a/app/assets/javascripts/flash_message.coffee +++ b/app/assets/javascripts/flash_message.coffee @@ -12,4 +12,4 @@ $(document).ajaxComplete (event, request) -> ready = -> $('#flash-message').children().first().delay(5000).slideUp 'medium' -$(document).on('turbolinks:load', ready) +$(document).on('turbo-migration:load', ready) diff --git a/app/assets/javascripts/groups.coffee b/app/assets/javascripts/groups.coffee index 474994025..3fbdd17b9 100644 --- a/app/assets/javascripts/groups.coffee +++ b/app/assets/javascripts/groups.coffee @@ -3,4 +3,4 @@ ready = -> $(e.currentTarget.parentElement).html(data["button"]) -$(document).on('turbolinks:load', ready) +$(document).on('turbo-migration:load', ready) diff --git a/app/assets/javascripts/labels_index.coffee b/app/assets/javascripts/labels_index.coffee index dc56c7a9f..6f0d58be4 100644 --- a/app/assets/javascripts/labels_index.coffee +++ b/app/assets/javascripts/labels_index.coffee @@ -185,4 +185,4 @@ ready = -> table = new LabelsTable(table_container); -$(document).on('turbolinks:load', ready) +$(document).on('turbo-migration:load', ready) diff --git a/app/assets/javascripts/markdown_editor.js b/app/assets/javascripts/markdown_editor.js index ade619d66..bcff19b22 100644 --- a/app/assets/javascripts/markdown_editor.js +++ b/app/assets/javascripts/markdown_editor.js @@ -180,7 +180,7 @@ const setResizeBtn = (formInput, editor) => { }); }; -$(document).on("turbolinks:load", function () { +$(document).on("turbo-migration:load", function () { initializeMarkdownEditors(); disableImageUpload(); }); diff --git a/app/assets/javascripts/nbp_wallet.js b/app/assets/javascripts/nbp_wallet.js index 21830e40e..3490c2b9d 100644 --- a/app/assets/javascripts/nbp_wallet.js +++ b/app/assets/javascripts/nbp_wallet.js @@ -26,7 +26,7 @@ const countdownValidity = () => { } }; -window.addEventListener("turbolinks:before-render", () => { +window.addEventListener("turbo:visit", () => { clearInterval(intervalID); clearTimeout(timeoutID); }); @@ -36,7 +36,7 @@ window.addEventListener("beforeunload", () => { clearTimeout(timeoutID); }); -$(document).on('turbolinks:load', function () { +$(document).on('turbo-migration:load', function () { if (window.location.pathname !== Routes.nbp_wallet_connect_users_path()) { return; } diff --git a/app/assets/javascripts/task_files.coffee b/app/assets/javascripts/task_files.coffee index 83d391a78..ed07a434b 100644 --- a/app/assets/javascripts/task_files.coffee +++ b/app/assets/javascripts/task_files.coffee @@ -68,4 +68,4 @@ initializeOnUpload = -> if fullPath $(this).parents('.file-container').find('.file-name').val(fullName) -$(document).on 'turbolinks:load', ready +$(document).on 'turbo-migration:load', ready diff --git a/app/assets/javascripts/tasks_base.coffee b/app/assets/javascripts/tasks_base.coffee index 28d972911..1dacb7d30 100644 --- a/app/assets/javascripts/tasks_base.coffee +++ b/app/assets/javascripts/tasks_base.coffee @@ -32,4 +32,4 @@ loadComments = (url, $wait_icon, $comment_box, onSucess)-> root = exports ? this; root.loadComments = loadComments -$(document).on('turbolinks:load', ready) +$(document).on('turbo-migration:load', ready) diff --git a/app/assets/javascripts/tasks_checklist.coffee b/app/assets/javascripts/tasks_checklist.coffee index 9ceabf9eb..b9766b16e 100644 --- a/app/assets/javascripts/tasks_checklist.coffee +++ b/app/assets/javascripts/tasks_checklist.coffee @@ -142,4 +142,4 @@ update_checklist = -> , 100 -$(document).on('turbolinks:load', initialize_checklist) +$(document).on('turbo-migration:load', initialize_checklist) diff --git a/app/assets/javascripts/tasks_export.coffee b/app/assets/javascripts/tasks_export.coffee index 6a5614206..eefaeaf77 100644 --- a/app/assets/javascripts/tasks_export.coffee +++ b/app/assets/javascripts/tasks_export.coffee @@ -55,4 +55,4 @@ exportConfirm = (taskId, accountLinkId, pushType) -> root = exports ? this; root.exportTaskStart = exportTaskStart -$(document).on('turbolinks:load', ready) +$(document).on('turbo-migration:load', ready) diff --git a/app/assets/javascripts/tasks_form.coffee b/app/assets/javascripts/tasks_form.coffee index cdf6a7d63..855eb71a0 100644 --- a/app/assets/javascripts/tasks_form.coffee +++ b/app/assets/javascripts/tasks_form.coffee @@ -31,5 +31,5 @@ initializeVisibilityWarning = -> warning_message.addClass('d-none') -$(document).on('turbolinks:load', ready) +$(document).on('turbo-migration:load', ready) $(document).on('select2:locales:loaded', initializeLoadSelect2) diff --git a/app/assets/javascripts/tasks_import.coffee b/app/assets/javascripts/tasks_import.coffee index 46a266174..43c999a3a 100644 --- a/app/assets/javascripts/tasks_import.coffee +++ b/app/assets/javascripts/tasks_import.coffee @@ -61,4 +61,4 @@ importConfirm = (importId, subfileId, importType) -> }) -$(document).on('turbolinks:load', ready) +$(document).on('turbo-migration:load', ready) diff --git a/app/assets/javascripts/tasks_index.coffee b/app/assets/javascripts/tasks_index.coffee index a53c4a47a..b4e979817 100644 --- a/app/assets/javascripts/tasks_index.coffee +++ b/app/assets/javascripts/tasks_index.coffee @@ -139,5 +139,5 @@ initializeInputFieldEnterCallback = -> event.preventDefault(); -$(document).on('turbolinks:load', ready) +$(document).on('turbo-migration:load', ready) $(document).on('select2:locales:loaded', initializeSelect2) diff --git a/app/assets/javascripts/tasks_ratings.coffee b/app/assets/javascripts/tasks_ratings.coffee index 60d055fa9..cf7638575 100644 --- a/app/assets/javascripts/tasks_ratings.coffee +++ b/app/assets/javascripts/tasks_ratings.coffee @@ -96,4 +96,4 @@ initializeRatings = -> ) -$(document).on('turbolinks:load', ready) +$(document).on('turbo-migration:load', ready) diff --git a/app/assets/javascripts/tasks_show.coffee b/app/assets/javascripts/tasks_show.coffee index e3296158c..9ee540cd3 100644 --- a/app/assets/javascripts/tasks_show.coffee +++ b/app/assets/javascripts/tasks_show.coffee @@ -22,4 +22,4 @@ initializeShowComments = -> $comment_box.addClass('hide') -$(document).on('turbolinks:load', ready) +$(document).on('turbo-migration:load', ready) diff --git a/app/assets/javascripts/users.coffee b/app/assets/javascripts/users.coffee index 637d951d1..4c78ac47f 100644 --- a/app/assets/javascripts/users.coffee +++ b/app/assets/javascripts/users.coffee @@ -14,4 +14,4 @@ ready = -> new bootstrap.Modal('#notification-modal').show(); -$(document).on('turbolinks:load', ready) +$(document).on('turbo-migration:load', ready) diff --git a/app/javascript/application.js b/app/javascript/application.js index ae0eca119..77e1d651d 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -51,10 +51,10 @@ import locales from "./generated/locales.json"; Promise.all( Object.keys(locales).map(locale => import(`select2/dist/js/i18n/${locale}`)) ).then(() => { - // Since there is a race condition between the locales and turbolinks, - // we don't know whether the locales are loaded before or after turbolinks. + // Since there is a race condition between the locales and Turbo, + // we don't know whether the locales are loaded before or after Turbo. // Therefore, we trigger the event in both cases. - $(document).on('turbolinks:load', () => + $(document).on('turbo-migration:load', () => $(document).trigger('select2:locales:loaded') ); $(document).trigger('select2:locales:loaded') @@ -85,3 +85,7 @@ import "ace-builds/src-noconflict/ext-language_tools"; // Enable autocompletion import "ace-builds/src-noconflict/ext-modelist"; // Enable language mode detection ace.config.set("useStrictCSP", true); // Enable strict CSP mode window.ace = ace; // Publish ace in global namespace + +// Turbo +import '@hotwired/turbo-rails'; +import './turbo-migration'; diff --git a/app/javascript/turbo-migration.js b/app/javascript/turbo-migration.js new file mode 100644 index 000000000..abe5b2f89 --- /dev/null +++ b/app/javascript/turbo-migration.js @@ -0,0 +1,35 @@ +// `turbo:load` is dispatched earlier than the previous `turbolinks:load` event. +// This is causing issues for our migration, since some assets are not fully loaded +// when the event is dispatched. To ensure that the DOM content is fully rendered, +// we use `requestAnimationFrame` to ensures that the DOM content is completely painted +// before dispatching a custom event `turbo-migration:load`. +// +// Further, we need to ensure that the `turbo-migration:load` event is only processed after +// Sprockets has loaded, since it would miss the event otherwise. +// +// We should remove this workaround once we fully migrated to Turbo and dropped Sprockets. + +let sprocketsLoaded = false; +const sprocketsLoadQueue = []; + +document.addEventListener('turbo:load', (event) => { + sprocketsLoaded ? forwardTurboLoad(event) : sprocketsLoadQueue.push(event); +}); + +// Wait for Sprockets to load before processing queued Turbo events +document.addEventListener('sprockets:load', () => { + sprocketsLoaded = true; + flushQueue(sprocketsLoadQueue); +}); + +function forwardTurboLoad(event) { + requestAnimationFrame(() => { + const delayedEvent = new CustomEvent('turbo-migration:load', { detail: { ...event.detail } }); + document.dispatchEvent(delayedEvent); + }); +} + +const flushQueue = (queue) => { + queue.forEach(forwardTurboLoad); + queue.length = 0; +}; diff --git a/app/views/application/_locale_selector.html.slim b/app/views/application/_locale_selector.html.slim index 0882da2dd..1e8a9a718 100644 --- a/app/views/application/_locale_selector.html.slim +++ b/app/views/application/_locale_selector.html.slim @@ -4,4 +4,4 @@ li.nav-item.dropdown span.caret ul.dropdown-menu.rounded-0 role='menu' - I18n.available_locales.sort_by {|locale| t("common.locales.#{locale}") }.each do |locale| - li = link_to(t("common.locales.#{locale}"), AuthenticatedUrlHelper.add_query_parameters(request.url, locale:), 'data-turbolinks' => 'false', class: 'dropdown-item') + li = link_to(t("common.locales.#{locale}"), AuthenticatedUrlHelper.add_query_parameters(request.url, locale:), 'data-turbo' => 'false', class: 'dropdown-item') diff --git a/app/views/application/_navigation.html.slim b/app/views/application/_navigation.html.slim index d7525aa8a..a473613b2 100644 --- a/app/views/application/_navigation.html.slim +++ b/app/views/application/_navigation.html.slim @@ -5,6 +5,6 @@ = render('navigation_collection_link', model: Group, cached: true) - if current_user.role == 'admin' li.nav-item - = link_to rails_admin.dashboard_path, 'data-turbolinks': false, class: 'nav-link px-3' do + = link_to rails_admin.dashboard_path, 'data-turbo': false, class: 'nav-link px-3' do i.fa-solid.fa-lock =< t('.rails_admin') diff --git a/app/views/collections/_form.html.slim b/app/views/collections/_form.html.slim index 4c335523f..4c4f54b16 100644 --- a/app/views/collections/_form.html.slim +++ b/app/views/collections/_form.html.slim @@ -1,7 +1,7 @@ - content_for :head do - // Force a full page reload, see https://github.com/turbolinks/turbolinks/issues/326. + // Force a full page reload, see https://turbo.hotwired.dev/handbook/drive#ensuring-specific-pages-trigger-a-full-reload. Otherwise, code might not be highlighted correctly (race condition) - meta name='turbolinks-visit-control' content='reload' + meta name='turbo-visit-control' content='reload' - append_javascript_pack_tag('toast-ui') - append_stylesheet_pack_tag('toast-ui') diff --git a/app/views/layouts/application.html.slim b/app/views/layouts/application.html.slim index 737122c38..7e6652e04 100644 --- a/app/views/layouts/application.html.slim +++ b/app/views/layouts/application.html.slim @@ -11,10 +11,10 @@ html lang=I18n.locale data-default-locale=I18n.default_locale = favicon_link_tag('/icon.svg', type: 'image/svg+xml') = favicon_link_tag('/icon.png', rel: 'apple-touch-icon', type: 'image/png') = tag.link rel: 'manifest', href: pwa_manifest_path - = stylesheet_pack_tag('stylesheets', "multilang_#{I18n.locale}", media: 'all', 'data-turbolinks-track': 'reload', integrity: true, crossorigin: 'anonymous') - = stylesheet_link_tag('application', media: 'all', 'data-turbolinks-track': 'reload', integrity: true, crossorigin: 'anonymous') - = javascript_pack_tag('application', 'data-turbolinks-track': 'reload', defer: false, integrity: true, crossorigin: 'anonymous') - = javascript_include_tag('application', 'data-turbolinks-track': 'reload', integrity: true, crossorigin: 'anonymous') + = stylesheet_pack_tag('stylesheets', "multilang_#{I18n.locale}", media: 'all', 'data-turbo-track': 'reload', integrity: true, crossorigin: 'anonymous') + = stylesheet_link_tag('application', media: 'all', 'data-turbo-track': 'reload', integrity: true, crossorigin: 'anonymous') + = javascript_pack_tag('application', 'data-turbo-track': 'reload', defer: false, integrity: true, crossorigin: 'anonymous') + = javascript_include_tag('application', 'data-turbo-track': 'reload', defer: true, integrity: true, crossorigin: 'anonymous') = yield(:head) = csrf_meta_tags /= csp_meta_tag diff --git a/app/views/tasks/_editor.html.slim b/app/views/tasks/_editor.html.slim index 5f0e7bf1a..1fe8a7569 100644 --- a/app/views/tasks/_editor.html.slim +++ b/app/views/tasks/_editor.html.slim @@ -18,7 +18,7 @@ a.btn.btn-main.btn-sm.toggle-input href='#' - if file.object.persisted? && file.object.attachment.image? = image_tag file.object.attachment.variant(resize_to_limit: [600, 600]) - elsif file.object.persisted? - = link_to file.object.attachment.filename, download_attachment_task_file_path(file.object), class: 'btn btn-light border-right-0', 'data-turbolinks' => false + = link_to file.object.attachment.filename, download_attachment_task_file_path(file.object), class: 'btn btn-light border-right-0', 'data-turbo' => false - else .btn.btn-light.border-right-0.disabled = file.object.attachment.filename = link_to '#', class: 'btn btn-light reupload-attachment border-left-0 me-1', title: t('.button.upload_a_new_file') do diff --git a/app/views/tasks/_form.html.slim b/app/views/tasks/_form.html.slim index e1caa783f..99fa96caa 100644 --- a/app/views/tasks/_form.html.slim +++ b/app/views/tasks/_form.html.slim @@ -1,7 +1,7 @@ - content_for :head do - // Force a full page reload, see https://github.com/turbolinks/turbolinks/issues/326. + // Force a full page reload, see https://turbo.hotwired.dev/handbook/drive#ensuring-specific-pages-trigger-a-full-reload. Otherwise, code might not be highlighted correctly (race condition) - meta name='turbolinks-visit-control' content='reload' + meta name='turbo-visit-control' content='reload' - append_javascript_pack_tag('toast-ui') - append_stylesheet_pack_tag('toast-ui') diff --git a/app/views/tasks/show.html.slim b/app/views/tasks/show.html.slim index 6d2abafff..2baf7007f 100644 --- a/app/views/tasks/show.html.slim +++ b/app/views/tasks/show.html.slim @@ -217,7 +217,7 @@ - if file.attachment.image? = image_tag(file.attachment.variant(resize_to_limit: [900, 900])) - else - = link_to file.attachment.filename, download_attachment_task_file_path(file), 'data-turbolinks': false + = link_to file.attachment.filename, download_attachment_task_file_path(file), 'data-turbo': false .row .col-auto.row-label = TaskFile.human_attribute_name('used_by_grader') @@ -358,7 +358,7 @@ - if file.attachment.image? = image_tag(file.attachment.variant(resize_to_limit: [900, 900])) - else - = link_to file.attachment.filename, download_attachment_task_file_path(file), 'data-turbolinks': false + = link_to file.attachment.filename, download_attachment_task_file_path(file), 'data-turbo': false .row .col-auto.row-label = TaskFile.human_attribute_name('used_by_grader') @@ -459,7 +459,7 @@ - if file.attachment.image? = image_tag(file.attachment.variant(resize_to_limit: [900, 900])) - else - = link_to file.attachment.filename, download_attachment_task_file_path(file), 'data-turbolinks': false + = link_to file.attachment.filename, download_attachment_task_file_path(file), 'data-turbo': false .row .col-auto.row-label = TaskFile.human_attribute_name('used_by_grader') @@ -529,7 +529,7 @@ - else div data-bs-toggle='tooltip' title=unavailable_tooltip data-bs-delay=150 - = link_to t('common.button.download_zip'), download_task_path(@task), class: 'btn btn-outline-dark disabled', 'data-turbolinks' => false + = link_to t('common.button.download_zip'), download_task_path(@task), class: 'btn btn-outline-dark disabled', 'data-turbo' => false - if policy(@task).export_external_start? .dropdown.btn-group @@ -587,7 +587,7 @@ .actions.btn-group role='group' - if policy(@task).edit? - = link_to @task.url_for(:edit), class: 'btn btn-important', 'data-turbolinks': false do + = link_to @task.url_for(:edit), class: 'btn btn-important', 'data-turbo': false do i.fa-solid.fa-pen-to-square =< t('common.button.edit') - if policy(@task).destroy? diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index b9e742e28..9f72df527 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -80,7 +80,7 @@ def self.get_host_source(url) # The `script_src` directive is only a fallback for browsers not supporting `script_src_elem` and `script_src_attr`. policy.script_src :self, :unsafe_inline, :report_sample # Some dependencies add new styles to the DOM dynamically, requiring :unsafe-inline. - # Currently, these include turbolinks, and vis.js. + # Currently, these include turbo, and vis.js. policy.style_src_elem :self, :unsafe_inline, :report_sample # We still use some inline styles within the application, and through the ToastUi markdown editor. policy.style_src_attr :unsafe_inline, :report_sample diff --git a/package.json b/package.json index 2413861e8..3c6bc3964 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@babel/preset-env": "7.28.0", "@babel/runtime": "7.27.6", "@fortawesome/fontawesome-free": "^6.7.2", + "@hotwired/turbo-rails": "^8.0.16", "@popperjs/core": "^2.11.8", "@sentry/browser": "^9.35.0", "@toast-ui/editor": "^3.2.2", diff --git a/spec/support/wait_for_ajax.rb b/spec/support/wait_for_ajax.rb index 9baf7bcff..293d45ced 100644 --- a/spec/support/wait_for_ajax.rb +++ b/spec/support/wait_for_ajax.rb @@ -7,7 +7,7 @@ def wait_for_ajax loop do sleep 0.1 # Short sleep time to prevent busy waiting - break if ajax_requests_finished? || (Time.current - start_time) > timeout + break if (ajax_requests_finished? && turbo_finished?) || (Time.current - start_time) > timeout end end @@ -16,6 +16,13 @@ def ajax_requests_finished? # Otherwise, Selenium and the browser driver might crash, preventing further tests from running. page.evaluate_script('jQuery.active').zero? end + + def turbo_finished? + # Check if Turbo is finished by looking for the absence of the progress bar. + if has_css?('.turbo-progress-bar', visible: true, wait: 0.1.seconds) + has_no_css?('.turbo-progress-bar') + end + end end RSpec.configure do |config| diff --git a/yarn.lock b/yarn.lock index 3e06d523c..eef3870a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1304,6 +1304,16 @@ __metadata: languageName: node linkType: hard +"@hotwired/turbo-rails@npm:^8.0.16": + version: 8.0.16 + resolution: "@hotwired/turbo-rails@npm:8.0.16" + dependencies: + "@hotwired/turbo": "npm:^8.0.13" + "@rails/actioncable": "npm:>=7.0" + checksum: 10c0/a3781bf5e7c798307754a726b5b66f63e4ec71a74e508549f257b3acf834ffc5c28baaf03b8dcdeeb7b35caa361769e417bbca629dcf873729398773585d35ca + languageName: node + linkType: hard + "@hotwired/turbo@npm:^7.3.0": version: 7.3.0 resolution: "@hotwired/turbo@npm:7.3.0" @@ -1311,6 +1321,13 @@ __metadata: languageName: node linkType: hard +"@hotwired/turbo@npm:^8.0.13": + version: 8.0.13 + resolution: "@hotwired/turbo@npm:8.0.13" + checksum: 10c0/fc9fd58ce2e006ad2f9e3948cf1ec71f47187ce8115f03e531bab849d0e13abc94cd0067f0888f7064d730b4c1a8212101bfa6d55f6166c6ad2db275e280149a + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -1655,6 +1672,13 @@ __metadata: languageName: node linkType: hard +"@rails/actioncable@npm:>=7.0": + version: 8.0.200 + resolution: "@rails/actioncable@npm:8.0.200" + checksum: 10c0/42c861f3b43131ab523d37125e30e802bd5bab98bf8150a780531f82f05177cd0071a75a7f1341eac9de772732625194ac16205a40b82dc567cc05c21c56e412 + languageName: node + linkType: hard + "@rails/actioncable@npm:^7.0": version: 7.2.201 resolution: "@rails/actioncable@npm:7.2.201" @@ -2745,6 +2769,7 @@ __metadata: "@babel/preset-env": "npm:7.28.0" "@babel/runtime": "npm:7.27.6" "@fortawesome/fontawesome-free": "npm:^6.7.2" + "@hotwired/turbo-rails": "npm:^8.0.16" "@popperjs/core": "npm:^2.11.8" "@sentry/browser": "npm:^9.35.0" "@toast-ui/editor": "npm:^3.2.2" From 44077d1177a3ac18f372318264d3df09b88fce2b Mon Sep 17 00:00:00 2001 From: Sebastian Serth Date: Fri, 27 Jun 2025 20:57:05 +0200 Subject: [PATCH 02/14] Remove unnecessary disable of Turbo These links work correctly with Turbo, so that we don't need to opt out here. --- app/views/application/_locale_selector.html.slim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/application/_locale_selector.html.slim b/app/views/application/_locale_selector.html.slim index 1e8a9a718..51ad3b873 100644 --- a/app/views/application/_locale_selector.html.slim +++ b/app/views/application/_locale_selector.html.slim @@ -4,4 +4,4 @@ li.nav-item.dropdown span.caret ul.dropdown-menu.rounded-0 role='menu' - I18n.available_locales.sort_by {|locale| t("common.locales.#{locale}") }.each do |locale| - li = link_to(t("common.locales.#{locale}"), AuthenticatedUrlHelper.add_query_parameters(request.url, locale:), 'data-turbo' => 'false', class: 'dropdown-item') + li = link_to(t("common.locales.#{locale}"), AuthenticatedUrlHelper.add_query_parameters(request.url, locale:), class: 'dropdown-item') From ed0123b54a71fb0f4562902f65b0fd61574c57af Mon Sep 17 00:00:00 2001 From: Sebastian Serth Date: Fri, 27 Jun 2025 10:04:31 +0200 Subject: [PATCH 03/14] Ensure HTTP status Unprocessable Content (422) on validation errors Turbo imposes a new requirement for form submissions: Whenever a form is submitted, a redirect is expected. When no redirect is performed (i.e., when the data submitted didn't pass validation and the same form is rendered again), a 4xx or 5xx error code is required. Therefore, this commit changes those occurrences to return a 422 error code. Without this change, an error would be logged by Turbo in the JavaScript console and the response (including the flash message) wouldn't be shown. See https://turbo.hotwired.dev/handbook/drive#redirecting-after-a-form-submission --- app/controllers/account_links_controller.rb | 4 ++-- app/controllers/collections_controller.rb | 4 ++-- app/controllers/groups_controller.rb | 4 ++-- app/controllers/messages_controller.rb | 2 +- app/controllers/task_contributions_controller.rb | 5 +++-- app/controllers/tasks_controller.rb | 4 ++-- spec/controllers/tasks_controller_spec.rb | 4 ++-- 7 files changed, 14 insertions(+), 13 deletions(-) diff --git a/app/controllers/account_links_controller.rb b/app/controllers/account_links_controller.rb index 0c81faed7..bd2c053c6 100644 --- a/app/controllers/account_links_controller.rb +++ b/app/controllers/account_links_controller.rb @@ -27,7 +27,7 @@ def create # rubocop:disable Metrics/AbcSize format.html { redirect_to @user, notice: t('common.notices.object_created', model: AccountLink.model_name.human) } format.json { render :show, status: :created, location: @account_link } else - format.html { render :new } + format.html { render :new, status: :unprocessable_content } format.json { render json: @account_link.errors, status: :unprocessable_content } end end @@ -39,7 +39,7 @@ def update format.html { redirect_to @account_link.user, notice: t('common.notices.object_updated', model: AccountLink.model_name.human) } format.json { render :show, status: :ok, location: @account_link } else - format.html { render :edit } + format.html { render :edit, status: :unprocessable_content } format.json { render json: @account_link.errors, status: :unprocessable_content } end end diff --git a/app/controllers/collections_controller.rb b/app/controllers/collections_controller.rb index f895f8e6a..7e94ac849 100644 --- a/app/controllers/collections_controller.rb +++ b/app/controllers/collections_controller.rb @@ -43,7 +43,7 @@ def create elsif @collection.save redirect_to Collection, notice: t('common.notices.object_created', model: Collection.model_name.human) else - render :new + render :new, status: :unprocessable_content end end @@ -51,7 +51,7 @@ def update if @collection.update(collection_params) redirect_to Collection, notice: t('common.notices.object_updated', model: Collection.model_name.human) else - render :edit + render :edit, status: :unprocessable_content end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 425ec9539..12cfaaf6c 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -32,7 +32,7 @@ def create if @group.save redirect_to @group, notice: t('common.notices.object_created', model: Group.model_name.human) else - render :new + render :new, status: :unprocessable_content end end @@ -40,7 +40,7 @@ def update if @group.update(group_params) redirect_to @group, notice: t('common.notices.object_updated', model: Group.model_name.human) else - render :edit + render :edit, status: :unprocessable_content end end diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb index 55403ae31..49cda58b1 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -35,7 +35,7 @@ def create if @message.save redirect_to user_messages_path(@user), notice: t('.sent_successfully') else - render :new + render :new, status: :unprocessable_content end end diff --git a/app/controllers/task_contributions_controller.rb b/app/controllers/task_contributions_controller.rb index 122071a74..514fe18f5 100644 --- a/app/controllers/task_contributions_controller.rb +++ b/app/controllers/task_contributions_controller.rb @@ -82,7 +82,7 @@ def create # rubocop:disable Metrics/AbcSize redirect_to [@task, @task_contribution], notice: t('common.notices.object_created', model: TaskContribution.model_name.human) else @task = @task_contribution.suggestion - render 'tasks/new' + render 'tasks/new', status: :unprocessable_content end end @@ -92,7 +92,8 @@ def update redirect_to [@task, @task_contribution], notice: t('common.notices.object_updated', model: TaskContribution.model_name.human) else @task = @task_contribution.suggestion - render 'tasks/edit', danger: t('common.errors.changes_not_saved', model: TaskContribution.model_name.human) + render 'tasks/edit', danger: t('common.errors.changes_not_saved', model: TaskContribution.model_name.human), + status: :unprocessable_content end end diff --git a/app/controllers/tasks_controller.rb b/app/controllers/tasks_controller.rb index 28e6b981f..0fb821a0f 100644 --- a/app/controllers/tasks_controller.rb +++ b/app/controllers/tasks_controller.rb @@ -59,7 +59,7 @@ def create if @task.save(context: :force_validations) redirect_to @task, notice: t('common.notices.object_created', model: Task.model_name.human) else - render :new + render :new, status: :unprocessable_content end end @@ -69,7 +69,7 @@ def update if @task.save(context: :force_validations) redirect_to @task, notice: t('common.notices.object_updated', model: Task.model_name.human) else - render :edit + render :edit, status: :unprocessable_content end end diff --git a/spec/controllers/tasks_controller_spec.rb b/spec/controllers/tasks_controller_spec.rb index e0602fcd5..dda4127c2 100644 --- a/spec/controllers/tasks_controller_spec.rb +++ b/spec/controllers/tasks_controller_spec.rb @@ -370,9 +370,9 @@ let(:invalid_attributes) { {title: '', label_names: [not_existing_label_name]} } let(:not_existing_label_name) { 'some new label' } - it "re-renders the 'new' template successfully" do + it "re-renders the 'new' template successfully with status 422" do post_request - expect(response).to have_http_status(:success) + expect(response).to have_http_status(:unprocessable_content) expect(response).to render_template('new') end end From d6dc487bdb19f8ddcded4324b600ee6b912043ff Mon Sep 17 00:00:00 2001 From: Sebastian Serth Date: Tue, 8 Jul 2025 00:02:48 +0200 Subject: [PATCH 04/14] Ensure HTTP status See Other (303) for form submissions Turbo requires us to use the HTTP status code 303 for successful form submissions after stateful server changes. See https://turbo.hotwired.dev/handbook/drive#redirecting-after-a-form-submission --- app/controllers/account_links_controller.rb | 14 +++++-- app/controllers/application_controller.rb | 6 +-- app/controllers/collections_controller.rb | 38 ++++++++++--------- app/controllers/groups_controller.rb | 24 ++++++------ app/controllers/messages_controller.rb | 6 +-- .../task_contributions_controller.rb | 20 ++++++---- app/controllers/task_files_controller.rb | 2 +- app/controllers/tasks_controller.rb | 18 ++++----- .../users/nbp_wallet_controller.rb | 10 ++--- .../users/omniauth_callbacks_controller.rb | 6 +-- app/controllers/users_controller.rb | 6 +-- spec/requests/collections_spec.rb | 16 ++++---- spec/requests/groups_spec.rb | 16 ++++---- spec/requests/tasks_spec.rb | 16 ++++---- spec/requests/users_spec.rb | 14 +++---- 15 files changed, 114 insertions(+), 98 deletions(-) diff --git a/app/controllers/account_links_controller.rb b/app/controllers/account_links_controller.rb index bd2c053c6..d6411b3ae 100644 --- a/app/controllers/account_links_controller.rb +++ b/app/controllers/account_links_controller.rb @@ -24,7 +24,9 @@ def create # rubocop:disable Metrics/AbcSize respond_to do |format| if @account_link.save - format.html { redirect_to @user, notice: t('common.notices.object_created', model: AccountLink.model_name.human) } + format.html do + redirect_to @user, notice: t('common.notices.object_created', model: AccountLink.model_name.human), status: :see_other + end format.json { render :show, status: :created, location: @account_link } else format.html { render :new, status: :unprocessable_content } @@ -36,7 +38,11 @@ def create # rubocop:disable Metrics/AbcSize def update respond_to do |format| if @account_link.update(account_link_params) - format.html { redirect_to @account_link.user, notice: t('common.notices.object_updated', model: AccountLink.model_name.human) } + format.html do + redirect_to @account_link.user, + notice: t('common.notices.object_updated', model: AccountLink.model_name.human), + status: :see_other + end format.json { render :show, status: :ok, location: @account_link } else format.html { render :edit, status: :unprocessable_content } @@ -48,7 +54,9 @@ def update def destroy @account_link.destroy respond_to do |format| - format.html { redirect_to @account_link.user, notice: t('common.notices.object_deleted', model: AccountLink.model_name.human) } + format.html do + redirect_to @account_link.user, notice: t('common.notices.object_deleted', model: AccountLink.model_name.human), status: :see_other + end format.json { head :no_content } end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 58d11b5af..eb2a41001 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -79,12 +79,12 @@ def render_error(message, status) # rubocop:disable Metrics/AbcSize respond_to do |format| format.any do if redirect_loop? || unauthorized_nbp_request?(status) - redirect_to :root, alert: message + redirect_to :root, alert: message, status: :see_other elsif current_user.nil? && status == :unauthorized store_location_for(:user, request.fullpath) if current_user.nil? - redirect_to new_user_session_path, alert: t('common.errors.not_signed_in') + redirect_to new_user_session_path, alert: t('common.errors.not_signed_in'), status: :see_other else - redirect_back fallback_location: :root, allow_other_host: false, alert: message + redirect_back fallback_location: :root, allow_other_host: false, alert: message, status: :see_other end end format.json { render json: {error: message}, status: } diff --git a/app/controllers/collections_controller.rb b/app/controllers/collections_controller.rb index 7e94ac849..0b93d0d29 100644 --- a/app/controllers/collections_controller.rb +++ b/app/controllers/collections_controller.rb @@ -41,7 +41,7 @@ def create # Redirect back to task#show as collections with tasks can only be created from that view create_collection_with_task elsif @collection.save - redirect_to Collection, notice: t('common.notices.object_created', model: Collection.model_name.human) + redirect_to Collection, notice: t('common.notices.object_created', model: Collection.model_name.human), status: :see_other else render :new, status: :unprocessable_content end @@ -49,7 +49,7 @@ def create def update if @collection.update(collection_params) - redirect_to Collection, notice: t('common.notices.object_updated', model: Collection.model_name.human) + redirect_to Collection, notice: t('common.notices.object_updated', model: Collection.model_name.human), status: :see_other else render :edit, status: :unprocessable_content end @@ -58,17 +58,17 @@ def update def remove_task redirect_target = params[:return_to_task] ? Task.find(params[:task]) : @collection if @collection.remove_task(params[:task]) - redirect_to redirect_target, notice: t('common.notices.object_removed', model: Task.model_name.human) + redirect_to redirect_target, notice: t('common.notices.object_removed', model: Task.model_name.human), status: :see_other else - redirect_to redirect_target, alert: t('.cannot_remove_alert') + redirect_to redirect_target, alert: t('.cannot_remove_alert'), status: :see_other end end def remove_all if @collection.remove_all - redirect_to @collection, notice: t('.success_notice') + redirect_to @collection, notice: t('.success_notice'), status: :see_other else - redirect_to @collection, alert: t('.cannot_remove_alert') + redirect_to @collection, alert: t('.cannot_remove_alert'), status: :see_other end end @@ -76,12 +76,12 @@ def push_collection errors = push_tasks if errors.empty? - redirect_to @collection, notice: t('.push_external_notice', account_link: @account_link.name) + redirect_to @collection, notice: t('.push_external_notice', account_link: @account_link.name), status: :see_other else errors.each do |error| logger.debug(error) end - redirect_to @collection, alert: t('.not_working', account_link: @account_link.name) + redirect_to @collection, alert: t('.not_working', account_link: @account_link.name), status: :see_other end end @@ -89,14 +89,14 @@ def download_all binary_zip_data = ProformaService::ExportTasks.call(tasks: @collection.tasks, options: {version: params[:version]}) send_data(binary_zip_data.string, type: 'application/zip', filename: "#{@collection.title}.zip", disposition: 'attachment') rescue ProformaXML::PostGenerateValidationError => e - redirect_to :root, danger: JSON.parse(e.message).map {|msg| t("proforma_errors.#{msg}", default: msg) }.join('
') + redirect_to :root, danger: JSON.parse(e.message).map {|msg| t("proforma_errors.#{msg}", default: msg) }.join('
'), status: :see_other end def share if share_message.save - redirect_to @collection, notice: t('.success_notice') + redirect_to @collection, notice: t('.success_notice'), status: :see_other else - redirect_to @collection, alert: share_message.errors.full_messages.join(', ') + redirect_to @collection, alert: share_message.errors.full_messages.join(', '), status: :see_other end end @@ -105,24 +105,28 @@ def view_shared end def save_shared # rubocop:disable Metrics/AbcSize - return redirect_to user_messages_path(current_user), alert: t('.errors.already_member') if @collection.users.include? current_user + if @collection.users.include? current_user + return redirect_to user_messages_path(current_user), + alert: t('.errors.already_member'), + status: :see_other + end ActiveRecord::Base.transaction(requires_new: true) do @collection.users << current_user message = Message.received_by(current_user).find_by(action: :collection_shared, attachment: @collection) message.mark_as_deleted(current_user) message.save! - redirect_to @collection, notice: t('.success_notice') + redirect_to @collection, notice: t('.success_notice'), status: :see_other end end def leave if @collection.users.one? @collection.destroy - redirect_to Collection, notice: t('common.notices.object_deleted', model: Collection.model_name.human) + redirect_to Collection, notice: t('common.notices.object_deleted', model: Collection.model_name.human), status: :see_other else @collection.users.delete(current_user) - redirect_to Collection, notice: t('.left_successfully') + redirect_to Collection, notice: t('.left_successfully'), status: :see_other end end @@ -134,14 +138,14 @@ def toggle_favorite current_user.favorite_collections << @collection flash_message = t('.favorite.added') end - redirect_to @collection, notice: flash_message + redirect_to @collection, notice: flash_message, status: :see_other end private def create_collection_with_task if @collection.save - redirect_to @collection.tasks.first, notice: t('collections.create.success_notice') + redirect_to @collection.tasks.first, notice: t('collections.create.success_notice'), status: :see_other else flash.now[:alert] = t('collections.create.error') end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 12cfaaf6c..2a5220b59 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -30,7 +30,7 @@ def create authorize @group if @group.save - redirect_to @group, notice: t('common.notices.object_created', model: Group.model_name.human) + redirect_to @group, notice: t('common.notices.object_created', model: Group.model_name.human), status: :see_other else render :new, status: :unprocessable_content end @@ -38,7 +38,7 @@ def create def update if @group.update(group_params) - redirect_to @group, notice: t('common.notices.object_updated', model: Group.model_name.human) + redirect_to @group, notice: t('common.notices.object_updated', model: Group.model_name.human), status: :see_other else render :edit, status: :unprocessable_content end @@ -46,15 +46,15 @@ def update def destroy @group.destroy - redirect_to Group, notice: t('common.notices.object_deleted', model: Group.model_name.human) + redirect_to Group, notice: t('common.notices.object_deleted', model: Group.model_name.human), status: :see_other end def leave if @group.last_admin?(current_user) - redirect_to @group, alert: t('.cannot_leave_alert') + redirect_to @group, alert: t('.cannot_leave_alert'), status: :see_other else @group.users.delete(current_user) - redirect_to Group, notice: t('.success_notice') + redirect_to Group, notice: t('.success_notice'), status: :see_other end end @@ -66,13 +66,13 @@ def request_access AccessRequestMailer.with(user: current_user, admin:, group: @group).send_access_request.deliver_later end @group.add(current_user, role: :applicant) - redirect_to Group + redirect_to Group, status: :see_other end def remove_task task = Task.find(params[:task]) @group.tasks.delete(task) - redirect_to @group, notice: t('common.notices.object_removed', model: Task.model_name.human) + redirect_to @group, notice: t('common.notices.object_removed', model: Task.model_name.human), status: :see_other end def grant_access @@ -80,12 +80,12 @@ def grant_access send_message(@user, :group_approval) @group.messages.where(sender: @user, recipient: current_user, action: :group_request).destroy_all - redirect_to @group, notice: t('.success_notice') + redirect_to @group, notice: t('.success_notice'), status: :see_other end def delete_from_group @group.users.delete(@user) - redirect_to @group, notice: t('.success_notice') + redirect_to @group, notice: t('.success_notice'), status: :see_other end def deny_access @@ -93,17 +93,17 @@ def deny_access send_message(@user, :group_rejection) @group.messages.where(sender: @user, recipient: current_user, action: :group_request).destroy_all - redirect_to @group, notice: t('.success_notice') + redirect_to @group, notice: t('.success_notice'), status: :see_other end def make_admin @group.make_admin(@user) - redirect_to @group, notice: t('.success_notice') + redirect_to @group, notice: t('.success_notice'), status: :see_other end def demote_admin @group.demote_admin(@user) - redirect_to @group, notice: t('.success_notice') + redirect_to @group, notice: t('.success_notice'), status: :see_other end private diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb index 49cda58b1..a0e3463e3 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -33,7 +33,7 @@ def create authorize @message if @message.save - redirect_to user_messages_path(@user), notice: t('.sent_successfully') + redirect_to user_messages_path(@user), notice: t('.sent_successfully'), status: :see_other else render :new, status: :unprocessable_content end @@ -44,9 +44,9 @@ def destroy if @message.save redirect_to user_messages_path(@user, option: params[:option]), - notice: t('common.notices.object_deleted', model: Message.model_name.human) + notice: t('common.notices.object_deleted', model: Message.model_name.human), status: :see_other else - redirect_to user_messages_path(@user, option: params[:option]), alert: t('.error') + redirect_to user_messages_path(@user, option: params[:option]), alert: t('.error'), status: :see_other end end diff --git a/app/controllers/task_contributions_controller.rb b/app/controllers/task_contributions_controller.rb index 514fe18f5..1a305091f 100644 --- a/app/controllers/task_contributions_controller.rb +++ b/app/controllers/task_contributions_controller.rb @@ -9,18 +9,18 @@ class TaskContributionsController < ApplicationController def approve_changes if @task.apply_contribution(@task_contribution) TaskContributionMailer.with(task_contrib: @task_contribution).send_approval_info.deliver_later - redirect_to @task, notice: t('.success') + redirect_to @task, notice: t('.success'), status: :see_other else - redirect_to [@task, @task_contribution], alert: t('.error') + redirect_to [@task, @task_contribution], alert: t('.error'), status: :see_other end end def discard_changes duplicate = @task_contribution.decouple if duplicate - redirect_to duplicate, notice: t('.success') + redirect_to duplicate, notice: t('.success'), status: :see_other else - redirect_to [@task, @task_contribution], alert: t('.error') + redirect_to [@task, @task_contribution], alert: t('.error'), status: :see_other end end @@ -28,9 +28,9 @@ def reject_changes duplicate = @task_contribution.decouple if duplicate TaskContributionMailer.with(task_contrib: @task_contribution, duplicate:).send_rejection_info.deliver_later - redirect_to @task, notice: t('.success') + redirect_to @task, notice: t('.success'), status: :see_other else - redirect_to [@task, @task_contribution], alert: t('.error') + redirect_to [@task, @task_contribution], alert: t('.error'), status: :see_other end end @@ -79,7 +79,9 @@ def create # rubocop:disable Metrics/AbcSize if @task_contribution.save(context: :force_validations) TaskContributionMailer.with(task_contrib: @task_contribution).send_contribution_request.deliver_later - redirect_to [@task, @task_contribution], notice: t('common.notices.object_created', model: TaskContribution.model_name.human) + redirect_to [@task, @task_contribution], + notice: t('common.notices.object_created', model: TaskContribution.model_name.human), + status: :see_other else @task = @task_contribution.suggestion render 'tasks/new', status: :unprocessable_content @@ -89,7 +91,9 @@ def create # rubocop:disable Metrics/AbcSize def update @task_contribution.suggestion.assign_attributes(task_params.except(:parent_uuid)) if @task_contribution.save(context: :force_validations) - redirect_to [@task, @task_contribution], notice: t('common.notices.object_updated', model: TaskContribution.model_name.human) + redirect_to [@task, @task_contribution], + notice: t('common.notices.object_updated', model: TaskContribution.model_name.human), + status: :see_other else @task = @task_contribution.suggestion render 'tasks/edit', danger: t('common.errors.changes_not_saved', model: TaskContribution.model_name.human), diff --git a/app/controllers/task_files_controller.rb b/app/controllers/task_files_controller.rb index d1d490ac9..47e99e355 100644 --- a/app/controllers/task_files_controller.rb +++ b/app/controllers/task_files_controller.rb @@ -4,7 +4,7 @@ class TaskFilesController < ApplicationController before_action :load_and_authorize_task_file def download_attachment - redirect_to rails_blob_path(@task_file.attachment, disposition: 'attachment') + redirect_to rails_blob_path(@task_file.attachment, disposition: 'attachment'), status: :see_other end def extract_text_data diff --git a/app/controllers/tasks_controller.rb b/app/controllers/tasks_controller.rb index 0fb821a0f..81105a8a0 100644 --- a/app/controllers/tasks_controller.rb +++ b/app/controllers/tasks_controller.rb @@ -27,9 +27,9 @@ def index def duplicate new_entry = @task.clean_duplicate(current_user) if new_entry.save(context: :force_validations) - redirect_to new_entry, notice: t('common.notices.object_duplicated', model: Task.model_name.human) + redirect_to new_entry, notice: t('common.notices.object_duplicated', model: Task.model_name.human), status: :see_other else - redirect_to @task, alert: t('.error_alert') + redirect_to @task, alert: t('.error_alert'), status: :see_other end end @@ -57,7 +57,7 @@ def create authorize @task if @task.save(context: :force_validations) - redirect_to @task, notice: t('common.notices.object_created', model: Task.model_name.human) + redirect_to @task, notice: t('common.notices.object_created', model: Task.model_name.human), status: :see_other else render :new, status: :unprocessable_content end @@ -67,7 +67,7 @@ def update @task.assign_attributes(task_params) TaskService::HandleGroups.call(user: current_user, task: @task, group_tasks_params:) if @task.save(context: :force_validations) - redirect_to @task, notice: t('common.notices.object_updated', model: Task.model_name.human) + redirect_to @task, notice: t('common.notices.object_updated', model: Task.model_name.human), status: :see_other else render :edit, status: :unprocessable_content end @@ -75,15 +75,15 @@ def update def destroy @task.destroy! - redirect_to Task, notice: t('common.notices.object_deleted', model: Task.model_name.human) + redirect_to Task, notice: t('common.notices.object_deleted', model: Task.model_name.human), status: :see_other end def add_to_collection collection = Collection.find(params[:collection]) if collection.add_task(@task) - redirect_to @task, notice: t('.success_notice') + redirect_to @task, notice: t('.success_notice'), status: :see_other else - redirect_to @task, alert: t('.error') + redirect_to @task, alert: t('.error'), status: :see_other end end @@ -91,7 +91,7 @@ def download zip_file = ProformaService::ExportTask.call(task: @task, options: {version: params[:version]}) send_data(zip_file.string, type: 'application/zip', filename: "task_#{@task.id}.zip", disposition: 'attachment') rescue ProformaXML::PostGenerateValidationError => e - redirect_to :root, danger: JSON.parse(e.message).map {|msg| t("proforma_errors.#{msg}", default: msg) }.join('
') + redirect_to :root, danger: JSON.parse(e.message).map {|msg| t("proforma_errors.#{msg}", default: msg) }.join('
'), status: :see_other end def import_start # rubocop:disable Metrics/AbcSize, Metrics/MethodLength @@ -213,7 +213,7 @@ def generate_test rescue Gpt::Error => e flash.now[:alert] = e.localized_message ensure - redirect_to @task + redirect_to @task, status: :see_other end private diff --git a/app/controllers/users/nbp_wallet_controller.rb b/app/controllers/users/nbp_wallet_controller.rb index a05cffa66..7ca26a317 100644 --- a/app/controllers/users/nbp_wallet_controller.rb +++ b/app/controllers/users/nbp_wallet_controller.rb @@ -8,14 +8,14 @@ class NbpWalletController < ApplicationController def connect if Enmeshed::Relationship.pending_for(@provider_uid).present? - redirect_to nbp_wallet_finalize_users_path and return + redirect_to(nbp_wallet_finalize_users_path, status: :see_other) and return end @relationship_template = Enmeshed::RelationshipTemplate.create!(nbp_uid: @provider_uid) rescue Enmeshed::ConnectorError, Faraday::Error => e Sentry.capture_exception(e) Rails.logger.debug { e } - redirect_to new_user_registration_path, alert: t('common.errors.generic_try_later') + redirect_to new_user_registration_path, alert: t('common.errors.generic_try_later'), status: :see_other end def qr_code @@ -32,7 +32,7 @@ def relationship_status rescue Enmeshed::ConnectorError, Faraday::Error => e Sentry.capture_exception(e) Rails.logger.debug { e } - redirect_to nbp_wallet_connect_users_path, alert: t('common.errors.generic') + redirect_to nbp_wallet_connect_users_path, alert: t('common.errors.generic'), status: :see_other end def finalize @@ -64,7 +64,7 @@ def accept_and_create_user(relationship) # rubocop:disable Metrics/AbcSize if relationship.accept! user.send_confirmation_instructions session.clear # Clear the session to prevent the user from accessing the NBP Wallet page again - redirect_to home_index_path, notice: t('devise.registrations.signed_up_but_unconfirmed') + redirect_to home_index_path, notice: t('devise.registrations.signed_up_but_unconfirmed'), status: :see_other else abort_and_refresh(relationship) raise ActiveRecord::Rollback @@ -80,7 +80,7 @@ def abort_and_refresh(relationship, reason = t('common.errors.generic')) Sentry.capture_exception(e) Rails.logger.debug { e } ensure - redirect_to nbp_wallet_connect_users_path, alert: reason + redirect_to nbp_wallet_connect_users_path, alert: reason, status: :see_other end def require_user! diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index ee71fe5b6..473610770 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -48,7 +48,7 @@ def deauthorize # rubocop:disable Metrics/AbcSize # i18n-tasks-use t('users.omniauth_callbacks.failure_deauthorize') set_flash(:alert, '.failure_deauthorize', reason: identity.errors.full_messages.join(', ')) end - redirect_to edit_user_registration_path + redirect_to edit_user_registration_path, status: :see_other end private @@ -89,7 +89,7 @@ def sign_in_with_identity def register_new_user # rubocop:disable Metrics/AbcSize if omniauth_provider == 'nbp' # go through NBP wallet connection process to create new account - redirect_to nbp_wallet_connect_users_path + redirect_to nbp_wallet_connect_users_path, status: :see_other else user = User.new_from_omniauth(omniauth_user_info, omniauth_provider, provider_uid) user.skip_confirmation! @@ -99,7 +99,7 @@ def register_new_user # rubocop:disable Metrics/AbcSize sign_in_and_redirect user, event: :authentication else set_flash(:alert, 'devise.omniauth_callbacks.failure', reason: user.errors.full_messages.join(', ')) - redirect_to new_user_registration_url + redirect_to new_user_registration_url, status: :see_other end end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 5a6c053f5..4949a901d 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -5,16 +5,16 @@ class UsersController < ApplicationController rescue_from Pundit::NotAuthorizedError do |_exception| if current_user - redirect_to({id: current_user.id}, alert: t('common.errors.not_authorized')) + redirect_to({id: current_user.id}, alert: t('common.errors.not_authorized'), status: :see_other) else - redirect_to :root, alert: t('common.errors.not_authorized') + redirect_to :root, alert: t('common.errors.not_authorized'), status: :see_other end end rescue_from ActiveRecord::RecordNotFound do |_exception| # leak no information whether a user exists or not if the accessing user is not an admin redirect_to({id: current_user.id}, - alert: current_user.role == 'admin' ? t('common.errors.not_found_error') : t('common.errors.not_authorized')) + alert: current_user.role == 'admin' ? t('common.errors.not_found_error') : t('common.errors.not_authorized'), status: :see_other) end def show; end diff --git a/spec/requests/collections_spec.rb b/spec/requests/collections_spec.rb index 5dddc42db..82842b038 100644 --- a/spec/requests/collections_spec.rb +++ b/spec/requests/collections_spec.rb @@ -21,9 +21,9 @@ end describe 'POST /collections' do - it 'has http 302' do + it 'responds with status 303' do post collections_path, params: {collection: collection_params} - expect(response).to have_http_status(:found) + expect(response).to have_http_status(:see_other) end end @@ -49,23 +49,23 @@ end describe 'PATCH /collection/:id' do - it 'has http 302' do + it 'responds with status 303' do patch collection_path(collection, collection: collection_params) - expect(response).to have_http_status(:found) + expect(response).to have_http_status(:see_other) end end describe 'PUT /collection/:id' do - it 'has http 302' do + it 'responds with status 303' do put collection_path(collection, collection: collection_params) - expect(response).to have_http_status(:found) + expect(response).to have_http_status(:see_other) end end describe 'POST /collection/:id/remove_task/:task_id' do - it 'has http 302' do + it 'responds with status 303' do patch remove_task_collection_path(collection, collection:, task:) - expect(response).to have_http_status(:found) + expect(response).to have_http_status(:see_other) end end end diff --git a/spec/requests/groups_spec.rb b/spec/requests/groups_spec.rb index 998dc7c63..402ea0f72 100644 --- a/spec/requests/groups_spec.rb +++ b/spec/requests/groups_spec.rb @@ -21,9 +21,9 @@ end describe 'POST /groups' do - it 'has http 302' do + it 'responds with status 303' do post groups_path, params: {group: group_params} - expect(response).to have_http_status(:found) + expect(response).to have_http_status(:see_other) end end @@ -49,23 +49,23 @@ end describe 'PATCH /group/:id' do - it 'has http 302' do + it 'responds with status 303' do patch group_path(group, group: group_params) - expect(response).to have_http_status(:found) + expect(response).to have_http_status(:see_other) end end describe 'PUT /group/:id' do - it 'has http 302' do + it 'responds with status 303' do put group_path(group, group: group_params) - expect(response).to have_http_status(:found) + expect(response).to have_http_status(:see_other) end end describe 'DELETE /group/:id' do - it 'has http 302' do + it 'responds with status 303' do delete group_path(group) - expect(response).to have_http_status(:found) + expect(response).to have_http_status(:see_other) end end end diff --git a/spec/requests/tasks_spec.rb b/spec/requests/tasks_spec.rb index ae176b50c..6edb29ff0 100644 --- a/spec/requests/tasks_spec.rb +++ b/spec/requests/tasks_spec.rb @@ -37,9 +37,9 @@ end describe 'POST /tasks' do - it 'has http 302' do + it 'responds with status 303' do post tasks_path, params: {task: valid_params, group_tasks: {group_ids: ['']}} - expect(response).to have_http_status(:found) + expect(response).to have_http_status(:see_other) end end @@ -65,23 +65,23 @@ end describe 'PATCH /task/:id' do - it 'has http 302' do + it 'responds with status 303' do patch task_path(task, task: update_params, group_tasks: {group_ids: ['']}) - expect(response).to have_http_status(:found) + expect(response).to have_http_status(:see_other) end end describe 'PUT /task/:id' do - it 'has http 302' do + it 'responds with status 303' do put task_path(task, task: update_params, group_tasks: {group_ids: ['']}) - expect(response).to have_http_status(:found) + expect(response).to have_http_status(:see_other) end end describe 'DELETE /task/:id' do - it 'has http 302' do + it 'responds with status 303' do delete task_path(task) - expect(response).to have_http_status(:found) + expect(response).to have_http_status(:see_other) end end end diff --git a/spec/requests/users_spec.rb b/spec/requests/users_spec.rb index af994f5ee..5f4dda0f2 100644 --- a/spec/requests/users_spec.rb +++ b/spec/requests/users_spec.rb @@ -22,7 +22,7 @@ end describe 'GET /users/sign_up' do - it 'returns http 302' do + it 'responds with status 302' do get new_user_registration_path expect(response).to have_http_status(:found) end @@ -43,23 +43,23 @@ end describe 'PATCH /users' do - it 'returns http 302' do + it 'responds with status 303' do patch user_registration_path(user, user: user_params, format: :html) - expect(response).to have_http_status(:found) + expect(response).to have_http_status(:see_other) end end describe 'PUT /users' do - it 'returns http 302' do + it 'responds with status 303' do put user_registration_path(user, user: user_params, format: :html) - expect(response).to have_http_status(:found) + expect(response).to have_http_status(:see_other) end end describe 'DELETE /users' do - it 'returns http 302' do + it 'responds with status 303' do delete user_registration_path(user, format: :html) - expect(response).to have_http_status(:found) + expect(response).to have_http_status(:see_other) end end end From da67c0ba37fc4c4a16eebbce1eccde18c0b5f466 Mon Sep 17 00:00:00 2001 From: Sebastian Serth Date: Tue, 8 Jul 2025 13:12:28 +0200 Subject: [PATCH 05/14] Migrate `link_to` to `button_to` for stateful actions in views For these actions, we use use a non-default `method` (other than `GET`). To indicate that within the DOM (i.e., for Screenreaders) but also enhance compatibility with Turbo, we switch to proper `button_to` forms here. As part of this migration, some buttons should still be shown inline (hence, we add the `form_class: 'd-inline-block'`) or with the same height as regular links (hence, we add `class: 'h-100'`). See https://github.com/heartcombo/devise/wiki/How-To:-Upgrade-to-Devise-4.9.0-%5BHotwire-Turbo-integration%5D#data-method-vs-data-turbo-method See https://gorails.com/episodes/link_to-vs-button_to-in-rails --- .../bootstrap_and_overrides.css.scss | 17 +++++++++++++ app/views/account_links/show.html.slim | 2 +- app/views/application/_session.html.slim | 2 +- app/views/collections/_form.html.slim | 2 +- app/views/collections/show.html.slim | 6 ++--- app/views/comments/_edit.html.slim | 2 +- app/views/comments/_task_comments.html.slim | 2 +- app/views/groups/_groups.html.slim | 4 ++-- app/views/groups/show.html.slim | 18 +++++++------- app/views/messages/index.html.slim | 8 +++---- app/views/tasks/_tasks.html.slim | 2 +- app/views/tasks/show.html.slim | 24 +++++++++---------- app/views/users/nbp_wallet/connect.html.slim | 2 +- app/views/users/registrations/edit.html.slim | 4 ++-- app/views/users/shared/_links.html.slim | 2 +- app/views/users/show.html.slim | 2 +- spec/support/expectations/has_form_button.rb | 15 ++++++++++++ spec/system/messages_system_spec.rb | 14 +++++------ 18 files changed, 80 insertions(+), 48 deletions(-) create mode 100644 spec/support/expectations/has_form_button.rb diff --git a/app/assets/stylesheets/bootstrap_and_overrides.css.scss b/app/assets/stylesheets/bootstrap_and_overrides.css.scss index 220726975..6090a9130 100644 --- a/app/assets/stylesheets/bootstrap_and_overrides.css.scss +++ b/app/assets/stylesheets/bootstrap_and_overrides.css.scss @@ -62,6 +62,23 @@ } .btn-group { + + // Align buttons in a link group, similar to `.btn-group .btn + .btn` etc. + .button_to { + &:not(:first-child) { + margin-left: -1px; + .btn { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + } + + &:not(:last-child) .btn { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + } + .btn-important { white-space: normal; padding: 10px 20px; diff --git a/app/views/account_links/show.html.slim b/app/views/account_links/show.html.slim index dd5a40268..7ca213c25 100644 --- a/app/views/account_links/show.html.slim +++ b/app/views/account_links/show.html.slim @@ -39,7 +39,7 @@ = link_to edit_user_account_link_path(@user, @account_link), class: 'btn btn-important' do i.fa-solid.fa-pen-to-square =< t('common.button.edit') - = link_to user_account_link_path(@user, @account_link), class: 'btn btn-important', method: :delete, data: {confirm: t('common.sure')} do + = button_to user_account_link_path(@user, @account_link), class: 'btn btn-important h-100', method: :delete, data: {confirm: t('common.sure')} do i.fa-solid.fa-trash-can.text-danger =< t('common.button.delete') = link_to t('common.button.back'), user_path(@user), class: 'btn btn-important' diff --git a/app/views/application/_session.html.slim b/app/views/application/_session.html.slim index 3f221aac8..033ce1144 100644 --- a/app/views/application/_session.html.slim +++ b/app/views/application/_session.html.slim @@ -14,7 +14,7 @@ - if current_user.unread_messages_count != '0' span.menu-number = current_user.unread_messages_count - li.nav-item = link_to t('.button.log_out'), destroy_user_session_path, method: :delete, class: 'nav-link px-3' + li.nav-item = button_to t('.button.log_out'), destroy_user_session_path, method: :delete, class: 'nav-link px-3' - else = render('navigation_collection_link', target: :new_user_registration_path, text: t('common.button.sign_up'), icon: 'fa-user', cached: true) = render('navigation_collection_link', target: :new_user_session_path, text: t('common.button.log_in'), icon: 'fa-right-to-bracket', cached: true) diff --git a/app/views/collections/_form.html.slim b/app/views/collections/_form.html.slim index 4c4f54b16..afb8b78f9 100644 --- a/app/views/collections/_form.html.slim +++ b/app/views/collections/_form.html.slim @@ -55,7 +55,7 @@ i.fa-solid.fa-folder-open =< t('.button.save') - if @collection.tasks.present? - = link_to remove_all_collection_path(@collection), method: :patch, data: {confirm: t('common.sure')}, class: 'btn btn-important' do + = button_to remove_all_collection_path(@collection), method: :patch, data: {confirm: t('common.sure')}, class: 'btn btn-important h-100' do i.fa-solid.fa-xmark.text-danger =< t('.button.remove_all') = link_to :back, class: 'btn btn-important' do diff --git a/app/views/collections/show.html.slim b/app/views/collections/show.html.slim index 318e9adb9..45caba4a8 100644 --- a/app/views/collections/show.html.slim +++ b/app/views/collections/show.html.slim @@ -74,7 +74,7 @@ .col-md-12.mb-4 .actions.btn-group role='group' - if action_name == 'view_shared' - = link_to t('.button.collaborate'), save_shared_collection_path(@collection), method: 'post', class: 'btn btn-important' + = button_to t('.button.collaborate'), save_shared_collection_path(@collection), method: 'post', class: 'btn btn-important h-100' = link_to t('common.button.back'), user_messages_path(current_user), class: 'btn btn-important' - else - if policy(@collection).edit? @@ -120,9 +120,9 @@ i.fa-solid.fa-pen-to-square =< t('common.button.edit') - if @collection.users.include? current_user - = link_to leave_collection_path(@collection), class: 'btn btn-important', method: :post, data: {confirm: @collection.users.size == 1 ? t('.leave.deletion_warning') : t('common.sure')} do + = button_to leave_collection_path(@collection), class: 'btn btn-important h-100', method: :post, data: {confirm: @collection.users.size == 1 ? t('.leave.deletion_warning') : t('common.sure')} do = @collection.users.size == 1 ? t('.button.delete') : t('.button.leave') - = link_to toggle_favorite_collection_path(@collection), class: 'btn btn-important', method: :post do + = button_to toggle_favorite_collection_path(@collection), class: 'btn btn-important h-100', method: :post do - if current_user.favorite_collections.include?(@collection) i.fa-regular.fa-star =< t('.button.unfavorite') diff --git a/app/views/comments/_edit.html.slim b/app/views/comments/_edit.html.slim index 8c4d2552e..d1abec60f 100644 --- a/app/views/comments/_edit.html.slim +++ b/app/views/comments/_edit.html.slim @@ -7,6 +7,6 @@ = button_tag type: 'submit', class: 'btn btn-light btn-sm' do i.fa-solid.fa-floppy-disk =< t('common.button.save') - = link_to task_comment_path(@task, @comment), class: 'btn btn-light btn-sm', method: :delete, remote: true, data: {confirm: t('common.sure')} + = button_to task_comment_path(@task, @comment), class: 'btn btn-light btn-sm', method: :delete, remote: true, data: {confirm: t('common.sure')} i.fa-solid.fa-trash-can =< t('common.button.delete') diff --git a/app/views/comments/_task_comments.html.slim b/app/views/comments/_task_comments.html.slim index 81f25dd4a..8aa3479bf 100644 --- a/app/views/comments/_task_comments.html.slim +++ b/app/views/comments/_task_comments.html.slim @@ -20,7 +20,7 @@ = link_to edit_task_comment_path(@task, comment), class: 'btn btn-light btn-sm', remote: true do i.fa-solid.fa-pen-to-square =< t('common.button.edit') - = link_to task_comment_path(@task, comment), class: 'btn btn-light btn-sm', method: :delete, remote: true, data: {confirm: t('common.sure')} + = button_to task_comment_path(@task, comment), class: 'btn btn-light btn-sm', method: :delete, remote: true, data: {confirm: t('common.sure')} i.fa-solid.fa-trash-can =< t('common.button.delete') .more-btn-wrapper diff --git a/app/views/groups/_groups.html.slim b/app/views/groups/_groups.html.slim index 64a9c36ce..8ece7be3c 100644 --- a/app/views/groups/_groups.html.slim +++ b/app/views/groups/_groups.html.slim @@ -34,11 +34,11 @@ - if policy(group).edit? = link_to edit_group_path(group), class: 'btn btn-light', data: {bs_toggle: 'tooltip', bs_placement: 'top', bs_title: t('common.button.edit')} do i.fas.fa-edit aria-hidden="true" - = link_to group, class: 'btn btn-light', method: :delete, data: {bs_toggle: 'tooltip', bs_placement: 'top', bs_title: t('common.button.delete')} do + = button_to group, class: 'btn btn-light', method: :delete, data: {bs_toggle: 'tooltip', bs_placement: 'top', bs_title: t('common.button.delete')} do i.fas.fa-trash-alt aria-hidden="true" - elsif group.applicants.include?(current_user) = t('.wait_to_get_accepted') - else - = link_to t('groups.shared.button.request_membership'), request_access_group_path(group), method: :post, class: 'btn btn-light' + = button_to t('groups.shared.button.request_membership'), request_access_group_path(group), method: :post, class: 'btn btn-light' = render('shared/pagination', collection: @groups) diff --git a/app/views/groups/show.html.slim b/app/views/groups/show.html.slim index 83c594daa..a02ac1216 100644 --- a/app/views/groups/show.html.slim +++ b/app/views/groups/show.html.slim @@ -16,7 +16,7 @@ .table-list => admin.email - if policy(@group).demote_admin? && @group.admins.length > 1 - = link_to demote_admin_group_path(group: @group, user: admin), method: :post, class: 'btn btn-light btn-sm', data: {confirm: admin == current_user ? t('.demote_yourself.confirm') : t('.demote_admin.confirm')} do + = button_to demote_admin_group_path(group: @group, user: admin), method: :post, form_class: 'd-inline-block', class: 'btn btn-light btn-sm', data: {confirm: admin == current_user ? t('.demote_yourself.confirm') : t('.demote_admin.confirm')} do i.fa-solid.fa-arrow-down =< (admin == current_user ? t('.button.demote_yourself') : t('.button.demote_admin')) @@ -30,10 +30,10 @@ => member.email - if policy(@group).edit? - if @group.group_membership_for(member).role_confirmed_member? - = link_to make_admin_group_path(group: @group, user: member), method: :post, class: 'btn btn-light btn-sm' do + = button_to make_admin_group_path(group: @group, user: member), method: :post, form_class: 'd-inline-block', class: 'btn btn-light btn-sm' do i.fa-solid.fa-plus =< t('.button.make_admin') - = link_to delete_from_group_group_path(group: @group, user: member), method: :patch, class: 'btn btn-light btn-sm' do + = button_to delete_from_group_group_path(group: @group, user: member), method: :patch, form_class: 'd-inline-block', class: 'btn btn-light btn-sm' do i.fa-solid.fa-xmark =< t('common.button.remove') - else @@ -50,10 +50,10 @@ .table-list => user.email .btn-group.float-end - = link_to grant_access_group_path(group: @group, user:), method: :post, class: 'btn btn-light btn-sm' do + = button_to grant_access_group_path(group: @group, user:), method: :post, form_class: 'd-inline-block', class: 'btn btn-light btn-sm' do i.fa-solid.fa-plus =< t('.button.grant_access') - = link_to deny_access_group_path(group: @group, user:), method: :post, class: 'btn btn-light btn-sm' do + = button_to deny_access_group_path(group: @group, user:), method: :post, form_class: 'd-inline-block', class: 'btn btn-light btn-sm' do i.fa-solid.fa-xmark =< t('.button.deny_access') - else @@ -67,7 +67,7 @@ .btn-group = link_to task.title, task_path(task), class: 'btn btn-light' - if policy(@group).remove_task? - = link_to remove_task_group_path(task:), method: :patch, class: 'btn btn-light' do + = button_to remove_task_group_path(task:), method: :patch, form_class: 'd-inline-block', class: 'btn btn-light' do i.fa-solid.fa-xmark.text-secondary - else = t('.has_no_tasks') @@ -76,14 +76,14 @@ .col-md-12.mb-4 .actions.btn-group role='group' - if policy(@group).request_access? - = link_to t('groups.shared.button.request_membership'), request_access_group_path(@group), method: :post, class: 'btn btn-important' + = button_to t('groups.shared.button.request_membership'), request_access_group_path(@group), method: :post, class: 'btn btn-important h-100' - if policy(@group).edit? = link_to edit_group_path(@group), class: 'btn btn-important' do i.fa-solid.fa-pen-to-square =< t('common.button.edit') - = link_to group_path(@group), class: 'btn btn-important', method: :delete, data: {confirm: t('common.sure')} do + = button_to group_path(@group), class: 'btn btn-important h-100', method: :delete, data: {confirm: t('common.sure')} do i.fa-solid.fa-trash-can.text-danger =< t('.button.delete_group') - if policy(@group).leave? - = link_to t('.button.leave'), leave_group_path(@group), method: :post, class: "leave-btn btn btn-important #{'disabled' if @group.last_admin?(current_user)}", data: {confirm: t('common.sure')} + = button_to t('.button.leave'), leave_group_path(@group), method: :post, class: "leave-btn btn btn-important h-100 #{'disabled' if @group.last_admin?(current_user)}", data: {confirm: t('common.sure')} = link_to t('common.button.back'), :back, class: 'btn btn-important' diff --git a/app/views/messages/index.html.slim b/app/views/messages/index.html.slim index 607fbc63b..7871fed3d 100644 --- a/app/views/messages/index.html.slim +++ b/app/views/messages/index.html.slim @@ -45,13 +45,13 @@ br - if message.attachment.present? - if message.action_collection_shared? = link_to t('.button.view'), view_shared_collection_path(message.attachment, user: message.sender), class: 'btn btn-light btn-sm' - = link_to t('.button.save_collection'), save_shared_collection_path(message.attachment), method: 'post', class: 'btn btn-light btn-sm' + = button_to t('.button.save_collection'), save_shared_collection_path(message.attachment), method: 'post', class: 'btn btn-light btn-sm' - if message.action_group_request? = link_to t('.button.view'), group_path(message.attachment), class: 'btn btn-light btn-sm' - = link_to grant_access_group_path(message.attachment, user: message.sender.id), method: :post, class: 'btn btn-light btn-sm' do + = button_to grant_access_group_path(message.attachment, user: message.sender.id), method: :post, class: 'btn btn-light btn-sm' do i.fa-solid.fa-plus.text-success =< t('.button.grant_access') - = link_to deny_access_group_path(message.attachment, user: message.sender.id), method: :post, class: 'btn btn-light btn-sm' do + = button_to deny_access_group_path(message.attachment, user: message.sender.id), method: :post, class: 'btn btn-light btn-sm' do i.fa-solid.fa-xmark.text-danger =< t('.button.deny_access') - if message.action_group_approval? @@ -63,7 +63,7 @@ br = link_to reply_user_messages_path(recipient: message.sender), class: 'btn btn-light btn-sm' do i.fa-solid.fa-reply =< t('.button.reply') - = link_to user_message_path(current_user, message, option: @option), method: :delete, class: 'btn btn-light btn-sm', data: {confirm: t('common.sure')} do + = button_to user_message_path(current_user, message, option: @option), method: :delete, class: 'btn btn-light btn-sm', data: {confirm: t('common.sure')} do i.fa-solid.fa-trash-can =< t('common.button.delete') = render('shared/pagination', collection: @messages) diff --git a/app/views/tasks/_tasks.html.slim b/app/views/tasks/_tasks.html.slim index e0630411b..9a75dc491 100644 --- a/app/views/tasks/_tasks.html.slim +++ b/app/views/tasks/_tasks.html.slim @@ -68,7 +68,7 @@ = link_to task.url_for(:edit), class: 'btn btn-light', data: {bs_toggle: 'tooltip', bs_placement: 'top', bs_title: t('common.button.edit')} do i.fas.fa-edit aria-hidden="true" - if policy(task).destroy? - = link_to task.url_for(:destroy), class: 'btn btn-light', method: :delete, data: {bs_toggle: 'tooltip', bs_placement: 'top', bs_title: t('common.button.delete'), confirm: t('common.sure')} do + = button_to task.url_for(:destroy), class: 'btn btn-light', method: :delete, data: {bs_toggle: 'tooltip', bs_placement: 'top', bs_title: t('common.button.delete'), confirm: t('common.sure')} do i.fas.fa-trash-alt aria-hidden="true" .card-footer.d-flex.align-items-center.pe-2 diff --git a/app/views/tasks/show.html.slim b/app/views/tasks/show.html.slim index 2baf7007f..eb244fd57 100644 --- a/app/views/tasks/show.html.slim +++ b/app/views/tasks/show.html.slim @@ -492,11 +492,11 @@ - current_user.collections.order(created_at: :desc).each do |collection| li - if collection.tasks.include?(@task) - = link_to remove_task_collection_path(collection, task: @task, return_to_task: true), method: :patch, class: 'dropdown-item', data: {confirm: t('.remove_task_from_collection_warning')} do + = button_to remove_task_collection_path(collection, task: @task, return_to_task: true), method: :patch, class: 'dropdown-item', data: {confirm: t('.remove_task_from_collection_warning')} do span.collection-checkmark = "✓ #{collection.title}" - else - = link_to add_to_collection_task_path(collection:), method: :post, class: 'dropdown-item' do + = button_to add_to_collection_task_path(collection:), method: :post, class: 'dropdown-item' do span.collection-checkmark = collection.title @@ -543,7 +543,7 @@ - else - current_user.available_account_links.each do |acc_link| li - = link_to acc_link.name, export_external_start_task_path(account_link: acc_link), method: :post, remote: true, class: 'dropdown-item export-test' + = button_to acc_link.name, export_external_start_task_path(account_link: acc_link), method: :post, remote: true, class: 'dropdown-item export-test' - else div data-bs-toggle='tooltip' title=unavailable_tooltip data-bs-delay=150 = button_tag class: 'btn btn-outline-dark dropdown-toggle disabled', 'data-bs-toggle': 'dropdown' do @@ -591,19 +591,19 @@ i.fa-solid.fa-pen-to-square =< t('common.button.edit') - if policy(@task).destroy? - = link_to task_path(@task), class: 'btn btn-important', method: :delete, data: {confirm: t('common.sure')} do + = button_to task_path(@task), class: 'btn btn-important h-100', method: :delete, data: {confirm: t('common.sure')} do i.fa-solid.fa-trash-can =< t('common.button.delete') - if policy(@task).approve_changes? - = link_to @task.url_for(:approve_changes), method: :post, class: 'btn btn-important' do + = button_to @task.url_for(:approve_changes), method: :post, class: 'btn btn-important h-100' do i.fa-solid.fa-check =< t('common.button.approve') - if policy(@task).reject_changes? - = link_to @task.url_for(:reject_changes), method: :post, class: 'btn btn-important' do + = button_to @task.url_for(:reject_changes), method: :post, class: 'btn btn-important h-100' do i.fa-solid.fa-xmark =< t('common.button.reject') - if policy(@task).discard_changes? - = link_to @task.url_for(:discard_changes), method: :post, class: 'btn btn-important' do + = button_to @task.url_for(:discard_changes), method: :post, class: 'btn btn-important h-100' do i.fa-solid.fa-link-slash =< t('task_contributions.buttons.decouple') @@ -619,27 +619,27 @@ =< t('task_contributions.buttons.contribute') - elsif @current_user.nil? .btn-group data-bs-toggle='tooltip' title=unavailable_tooltip data-bs-delay=150 - = link_to new_task_task_contribution_path(@task), method: :post, class: 'btn btn-important disabled' do + = button_to new_task_task_contribution_path(@task), method: :post, class: 'btn btn-important disabled h-100' do i.fa-regular.fa-clone =< t('task_contributions.buttons.contribute') - if policy(@task).duplicate? - = link_to duplicate_task_path(@task), method: :post, class: 'btn btn-important' do + = button_to duplicate_task_path(@task), method: :post, class: 'btn btn-important h-100' do i.fa-regular.fa-clone =< t('common.button.duplicate') - elsif @current_user.nil? .btn-group data-bs-toggle='tooltip' title=unavailable_tooltip data-bs-delay=150 - = link_to duplicate_task_path(@task), method: :post, class: 'btn btn-important disabled' do + = button_to duplicate_task_path(@task), method: :post, class: 'btn btn-important disabled h-100' do i.fa-regular.fa-clone =< t('common.button.duplicate') - if policy(@task).generate_test? - = link_to generate_test_task_path(@task), method: :post, class: 'btn btn-important' do + = button_to generate_test_task_path(@task), method: :post, class: 'btn btn-important h-100' do i.fa-solid.fa-wand-magic-sparkles = t('.button.generate_test') - elsif policy(@task).update? .btn-group data-bs-toggle='tooltip' title=t('.button.api_key_required') data-bs-delay=150 - = link_to generate_test_task_path(@task), method: :post, class: 'btn btn-important disabled' do + = button_to generate_test_task_path(@task), method: :post, class: 'btn btn-important disabled h-100' do i.fa-solid.fa-wand-magic-sparkles = t('.button.generate_test') diff --git a/app/views/users/nbp_wallet/connect.html.slim b/app/views/users/nbp_wallet/connect.html.slim index d5e84ecdf..97570b190 100644 --- a/app/views/users/nbp_wallet/connect.html.slim +++ b/app/views/users/nbp_wallet/connect.html.slim @@ -12,7 +12,7 @@ class: 'img-fluid pixelated' .btn.btn-primary.w-100.mt-3 data-behavior='reload-on-click' = t('.regenerate_code') - = link_to destroy_user_session_path, method: :delete, class: 'btn btn-outline-danger w-100 mt-3' + = button_to destroy_user_session_path, method: :delete, class: 'btn btn-outline-danger w-100 mt-3' = t('.cancel_registration') .col-8.col-md-6.mt-3.ms-md-3.mt-md-0 p.fs-6 diff --git a/app/views/users/registrations/edit.html.slim b/app/views/users/registrations/edit.html.slim index 52a87473b..c7c7a378f 100644 --- a/app/views/users/registrations/edit.html.slim +++ b/app/views/users/registrations/edit.html.slim @@ -83,12 +83,12 @@ - provider_name = OmniAuth::Utils.camelize(provider) - configured_providers = current_user.omniauth_identities.pluck(:omniauth_provider) - if configured_providers.include?(provider.to_s) && (configured_providers.size > 1 || resource.password_set?) - = link_to t('.remove_identity', kind: provider_name), omniauth_deauthorize_path(provider), class: 'btn btn-light btn-sm mb-2 me-2', method: :delete + = button_to t('.remove_identity', kind: provider_name), omniauth_deauthorize_path(provider), class: 'btn btn-light btn-sm mb-2 me-2', method: :delete - elsif configured_providers.include?(provider.to_s) a.btn.btn-light.btn-sm.mb-2.me-2.disabled href='#' = t('.cannot_remove_last_identity', kind: provider_name) - else - = link_to t('.add_identity', kind: provider_name), omniauth_authorize_path(resource_name, provider), class: 'btn btn-light btn-sm mb-2 me-2', method: :post + = button_to t('.add_identity', kind: provider_name), omniauth_authorize_path(resource_name, provider), class: 'btn btn-light btn-sm mb-2 me-2', method: :post .form-group.py-3 .btn-group role='group' diff --git a/app/views/users/shared/_links.html.slim b/app/views/users/shared/_links.html.slim index 769086f22..c43e5cd6b 100644 --- a/app/views/users/shared/_links.html.slim +++ b/app/views/users/shared/_links.html.slim @@ -21,6 +21,6 @@ - if devise_mapping.omniauthable? - resource_class.omniauth_providers.each do |provider| - = link_to t('devise.shared.links.sign_in_with_provider', provider: OmniAuth::Utils.camelize(provider)), + = button_to t('devise.shared.links.sign_in_with_provider', provider: OmniAuth::Utils.camelize(provider)), omniauth_authorize_path(resource_name, provider), method: :post br diff --git a/app/views/users/show.html.slim b/app/views/users/show.html.slim index e7c845dd2..d727ea9f3 100644 --- a/app/views/users/show.html.slim +++ b/app/views/users/show.html.slim @@ -81,7 +81,7 @@ = link_to edit_user_account_link_path(current_user, account_link), class: 'btn btn-light btn-sm' do i.fa-solid.fa-pen-to-square =< t('common.button.edit') - = link_to user_account_link_path(current_user, account_link), method: :delete, data: {confirm: t('common.sure')}, class: 'btn btn-light btn-sm' do + = button_to user_account_link_path(current_user, account_link), method: :delete, data: {confirm: t('common.sure')}, class: 'btn btn-light btn-sm' do i.fa-solid.fa-trash-can =< t('common.button.delete') - if @user.shared_account_links.any? diff --git a/spec/support/expectations/has_form_button.rb b/spec/support/expectations/has_form_button.rb new file mode 100644 index 000000000..c449f9484 --- /dev/null +++ b/spec/support/expectations/has_form_button.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +RSpec::Matchers.define :have_form_button do |action: nil| + match do |page| + page.has_selector?(:xpath, ".//form[@action='#{action}']/button") + end + + failure_message do |_page| + "expected that the page would have a form with the action: #{action}" + end + + failure_message_when_negated do |_page| + "expected that the page would not have a form button with the action: #{action}" + end +end diff --git a/spec/system/messages_system_spec.rb b/spec/system/messages_system_spec.rb index 73acc3a3b..486d7cfc2 100644 --- a/spec/system/messages_system_spec.rb +++ b/spec/system/messages_system_spec.rb @@ -20,16 +20,16 @@ let(:view_shared_link) { view_shared_collection_path(collection, user: collection_owner) } let(:save_shared_link) { save_shared_collection_path(collection) } - it 'shows the message with a link to the collection' do + it 'shows the message with actions to the collection' do view_inbox expect(page).to have_link(href: view_shared_link) - expect(page).to have_link(href: save_shared_link) + expect(page).to have_form_button(action: save_shared_link) end it 'adds the user to the collection when saving the collection' do view_inbox expect do - find("[href='#{save_shared_link}'").click + click_button(I18n.t('messages.index.button.save_collection')) wait_for_ajax end.to change { collection.users.reload.include? user }.from(false).to(true) end @@ -58,16 +58,16 @@ let(:grant_access_link) { grant_access_group_path(group, user: sender) } let(:deny_access_link) { deny_access_group_path(group, user: sender) } - it 'shows the message with links to grant or deny the request' do + it 'shows the message with actions to grant or deny the request' do view_inbox - expect(page).to have_link(href: grant_access_link) - expect(page).to have_link(href: deny_access_link) + expect(page).to have_form_button(action: grant_access_link) + expect(page).to have_form_button(action: deny_access_link) end it 'confirms the sender as a group member when clicking the grant access link' do view_inbox expect do - find("[href='#{grant_access_link}'").click + click_button(I18n.t('messages.index.button.grant_access')) wait_for_ajax end.to change { group.reload.confirmed_member? sender }.from(false).to(true) end From 8c35a8b8de36808c8f213215432cccaa6201025f Mon Sep 17 00:00:00 2001 From: Sebastian Serth Date: Tue, 8 Jul 2025 00:00:15 +0200 Subject: [PATCH 06/14] Update Devise initializer for Turbo Similar to all other controllers, Devise also needs to respect the updated HTTP status codes for Turbo. Luckily, we use the newest Devise version already, which comes with built-in support for Turbo already. See https://github.com/heartcombo/devise/wiki/How-To:-Upgrade-to-Devise-4.9.0-%5BHotwire-Turbo-integration%5D --- config/initializers/devise.rb | 18 ++++++++++-------- spec/requests/users_spec.rb | 7 ++++--- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 709995ffb..08f695da4 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -262,14 +262,14 @@ # ==> Navigation configuration # Lists the formats that should be treated as navigational. Formats like - # :html, should redirect to the sign in page when the user does not have + # :html should redirect to the sign in page when the user does not have # access, but formats like :xml or :json, should return 401. # # If you have any extra navigational formats, like :iphone or :mobile, you # should add them to the navigational formats lists. # # The "*/*" below is required to match Internet Explorer requests. - # config.navigational_formats = ['*/*', :html] + # config.navigational_formats = ['*/*', :html, :turbo_stream] # The default HTTP method used to sign out a resource. Default is :delete. config.sign_out_via = :delete @@ -305,12 +305,14 @@ # so you need to do it manually. For the users scope, it would be: # config.omniauth_path_prefix = '/my_engine/users/auth' - # ==> Turbolinks configuration - # If your app is using Turbolinks, Turbolinks::Controller needs to be included to make redirection work correctly: - # - # ActiveSupport.on_load(:devise_failure_app) do - # include Turbolinks::Controller - # end + # ==> Hotwire/Turbo configuration + # When using Devise with Hotwire/Turbo, the http status for error responses + # and some redirects must match the following. The default in Devise for existing + # apps is `200 OK` and `302 Found` respectively, but new apps are generated with + # these new defaults that match Hotwire/Turbo behavior. + # Note: These might become the new default in future versions of Devise. + config.responder.error_status = :unprocessable_entity + config.responder.redirect_status = :see_other # ==> Configuration for :registerable diff --git a/spec/requests/users_spec.rb b/spec/requests/users_spec.rb index 5f4dda0f2..c09b445f6 100644 --- a/spec/requests/users_spec.rb +++ b/spec/requests/users_spec.rb @@ -7,9 +7,10 @@ context 'when not logged in' do describe '/users/sign_in' do - it 'returns http 200' do - post user_session_path, params: {email: user.email, password: user.password} - expect(response).to have_http_status(:ok) + it 'responds with status 303' do + post user_session_path, params: {user: {email: user.email, password: user.password}} + expect(response).to have_http_status(:see_other) + expect(response).to redirect_to(root_path) end end end From 53c26b1a175d581db020602efe139462ac236318 Mon Sep 17 00:00:00 2001 From: Sebastian Serth Date: Sun, 29 Jun 2025 18:06:33 +0200 Subject: [PATCH 07/14] Re-initialize JavaScript elements on failed form submission When the form is submitted but no redirect occurs (e.g., after a validation error), the page content is simply replaced. However, for this occasion Turbo misses to emit a new `turbo:load` event, which makes it more difficult to use custom JavaScript waiting on page initializations. Potentially, we could use `turbo:render` here to fix these occasions. --- app/javascript/turbo-migration.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/javascript/turbo-migration.js b/app/javascript/turbo-migration.js index abe5b2f89..5498c40b9 100644 --- a/app/javascript/turbo-migration.js +++ b/app/javascript/turbo-migration.js @@ -11,6 +11,7 @@ let sprocketsLoaded = false; const sprocketsLoadQueue = []; +const turboRenderQueue = []; document.addEventListener('turbo:load', (event) => { sprocketsLoaded ? forwardTurboLoad(event) : sprocketsLoadQueue.push(event); @@ -22,6 +23,25 @@ document.addEventListener('sprockets:load', () => { flushQueue(sprocketsLoadQueue); }); +// Handle failed form submissions by waiting for `turbo:render` events +document.addEventListener('turbo:submit-end', (event) => { + if (!event.detail.success) { + // If the form submission was _not_ successful, we need to re-initialize JavaScript elements. + // This is necessary since Turbo does not dispatch a `turbo:load` event in this case. + turboRenderQueue.push(event); + } +}); + +document.addEventListener('turbo:render', () => { + if (sprocketsLoaded) { + flushQueue(turboRenderQueue); + } else { + // In the unlikely case that Sprockets isn't ready yet, we queue the events. + sprocketsLoadQueue.push(...turboRenderQueue); + turboRenderQueue.length = 0; + } +}); + function forwardTurboLoad(event) { requestAnimationFrame(() => { const delayedEvent = new CustomEvent('turbo-migration:load', { detail: { ...event.detail } }); From 0857c6d4b91ca835c619cf9af45393abfcbef05c Mon Sep 17 00:00:00 2001 From: Sebastian Serth Date: Fri, 27 Jun 2025 10:04:54 +0200 Subject: [PATCH 08/14] Disable Turbo for form submissions with custom packs With Turbo enabled on forms with custom JavaScript / CSS packs and a successful change (no validation error), the final page would be rendered twice: The first rendering would occur after sending the form data using Turbo. This response is fetched and potentially displayed (first visit). The resulting DOM contains a change in the packs (with `'data-turbo-track': 'reload'`), causing a page reload (without Turbo). This is the second visit. Through this "double-visit", any flash message intended to be shown after the form submission is simply "lost". Therefore, we disable Turbo in these cases. This holds also true for signout actions, that can (potentially) originate from any page, including those with custom packs, and redirect to the root page (without any additional packs). --- app/views/application/_session.html.slim | 2 +- app/views/collections/_form.html.slim | 2 +- app/views/tasks/_form.html.slim | 2 +- app/views/users/nbp_wallet/connect.html.slim | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/application/_session.html.slim b/app/views/application/_session.html.slim index 033ce1144..6cef15bb0 100644 --- a/app/views/application/_session.html.slim +++ b/app/views/application/_session.html.slim @@ -14,7 +14,7 @@ - if current_user.unread_messages_count != '0' span.menu-number = current_user.unread_messages_count - li.nav-item = button_to t('.button.log_out'), destroy_user_session_path, method: :delete, class: 'nav-link px-3' + li.nav-item = button_to t('.button.log_out'), destroy_user_session_path, method: :delete, class: 'nav-link px-3', data: {turbo: false} - else = render('navigation_collection_link', target: :new_user_registration_path, text: t('common.button.sign_up'), icon: 'fa-user', cached: true) = render('navigation_collection_link', target: :new_user_session_path, text: t('common.button.log_in'), icon: 'fa-right-to-bracket', cached: true) diff --git a/app/views/collections/_form.html.slim b/app/views/collections/_form.html.slim index afb8b78f9..92d2cba4a 100644 --- a/app/views/collections/_form.html.slim +++ b/app/views/collections/_form.html.slim @@ -5,7 +5,7 @@ - append_javascript_pack_tag('toast-ui') - append_stylesheet_pack_tag('toast-ui') -= form_for @collection, builder: MarkdownFormBuilder do |f| += form_for @collection, builder: MarkdownFormBuilder, data: {turbo: false} do |f| .my-4 = render('shared/form_errors', object: @collection) .form-group diff --git a/app/views/tasks/_form.html.slim b/app/views/tasks/_form.html.slim index 99fa96caa..4ff56e873 100644 --- a/app/views/tasks/_form.html.slim +++ b/app/views/tasks/_form.html.slim @@ -14,7 +14,7 @@ - url = task_task_contribution_path(@task.parent, @task.task_contribution) - else - url = nil - = simple_form_for @task, url:, html: {multipart: true}, builder: MarkdownFormBuilder do |f| + = simple_form_for @task, url:, html: {multipart: true}, builder: MarkdownFormBuilder, data: {turbo: false} do |f| = render('shared/form_errors', object: @task) fieldset.form-group legend.toggle-next diff --git a/app/views/users/nbp_wallet/connect.html.slim b/app/views/users/nbp_wallet/connect.html.slim index 97570b190..c5cfb9f25 100644 --- a/app/views/users/nbp_wallet/connect.html.slim +++ b/app/views/users/nbp_wallet/connect.html.slim @@ -12,7 +12,7 @@ class: 'img-fluid pixelated' .btn.btn-primary.w-100.mt-3 data-behavior='reload-on-click' = t('.regenerate_code') - = button_to destroy_user_session_path, method: :delete, class: 'btn btn-outline-danger w-100 mt-3' + = button_to destroy_user_session_path, method: :delete, class: 'btn btn-outline-danger w-100 mt-3', data: {turbo: false} = t('.cancel_registration') .col-8.col-md-6.mt-3.ms-md-3.mt-md-0 p.fs-6 From da669c80de53ea73e506231a9acad65277bc891b Mon Sep 17 00:00:00 2001 From: Sebastian Serth Date: Sun, 29 Jun 2025 20:20:31 +0200 Subject: [PATCH 09/14] Remove meta name='turbo-visit-control' content='reload' --- app/views/collections/_form.html.slim | 3 --- app/views/tasks/_form.html.slim | 3 --- 2 files changed, 6 deletions(-) diff --git a/app/views/collections/_form.html.slim b/app/views/collections/_form.html.slim index 92d2cba4a..a2881b23a 100644 --- a/app/views/collections/_form.html.slim +++ b/app/views/collections/_form.html.slim @@ -1,7 +1,4 @@ - content_for :head do - // Force a full page reload, see https://turbo.hotwired.dev/handbook/drive#ensuring-specific-pages-trigger-a-full-reload. - Otherwise, code might not be highlighted correctly (race condition) - meta name='turbo-visit-control' content='reload' - append_javascript_pack_tag('toast-ui') - append_stylesheet_pack_tag('toast-ui') diff --git a/app/views/tasks/_form.html.slim b/app/views/tasks/_form.html.slim index 4ff56e873..e6b81fb25 100644 --- a/app/views/tasks/_form.html.slim +++ b/app/views/tasks/_form.html.slim @@ -1,7 +1,4 @@ - content_for :head do - // Force a full page reload, see https://turbo.hotwired.dev/handbook/drive#ensuring-specific-pages-trigger-a-full-reload. - Otherwise, code might not be highlighted correctly (race condition) - meta name='turbo-visit-control' content='reload' - append_javascript_pack_tag('toast-ui') - append_stylesheet_pack_tag('toast-ui') From e26f6b6d4a69ac1d93fe7a822c711101284cdd61 Mon Sep 17 00:00:00 2001 From: Sebastian Serth Date: Thu, 26 Jun 2025 10:01:47 +0200 Subject: [PATCH 10/14] Move initialization of tooltips to app/javascript With this new method, Tooltips are correctly initialized and potential loading errors/race conditions are prevented. --- app/assets/javascripts/base.js | 3 --- app/assets/javascripts/bootstrap.js | 4 ---- app/javascript/tooltips.js | 11 +++++++++++ app/javascript/turbo-migration.js | 8 ++++++++ 4 files changed, 19 insertions(+), 7 deletions(-) delete mode 100644 app/assets/javascripts/bootstrap.js create mode 100644 app/javascript/tooltips.js diff --git a/app/assets/javascripts/base.js b/app/assets/javascripts/base.js index 2b25745da..ba08ab328 100644 --- a/app/assets/javascripts/base.js +++ b/app/assets/javascripts/base.js @@ -55,9 +55,6 @@ $(document).on('turbo-migration:load', function () { }); } - // Enable all tooltips - $('[data-bs-toggle="tooltip"]').tooltip(); - // Ensure that the tab button will be in active (selected) state for each page that has 'option' query (e.g. groups or messages page) $('#' + $('#option').val()).addClass('selected'); }); diff --git a/app/assets/javascripts/bootstrap.js b/app/assets/javascripts/bootstrap.js deleted file mode 100644 index a0cdc7705..000000000 --- a/app/assets/javascripts/bootstrap.js +++ /dev/null @@ -1,4 +0,0 @@ -jQuery(function () { - $("a[rel~=popover], .has-popover").popover(); - $("a[rel~=tooltip], .has-tooltip").tooltip(); -}); diff --git a/app/javascript/tooltips.js b/app/javascript/tooltips.js new file mode 100644 index 000000000..da77fea64 --- /dev/null +++ b/app/javascript/tooltips.js @@ -0,0 +1,11 @@ +let tooltipList = []; + +export function initializeTooltips() { + const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); + tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)); +} + +export function destroyTooltips() { + tooltipList.map(tooltip => tooltip.dispose()); + tooltipList = []; +} diff --git a/app/javascript/turbo-migration.js b/app/javascript/turbo-migration.js index 5498c40b9..ad57ec6d3 100644 --- a/app/javascript/turbo-migration.js +++ b/app/javascript/turbo-migration.js @@ -1,3 +1,5 @@ +import { initializeTooltips, destroyTooltips } from './tooltips'; + // `turbo:load` is dispatched earlier than the previous `turbolinks:load` event. // This is causing issues for our migration, since some assets are not fully loaded // when the event is dispatched. To ensure that the DOM content is fully rendered, @@ -46,6 +48,8 @@ function forwardTurboLoad(event) { requestAnimationFrame(() => { const delayedEvent = new CustomEvent('turbo-migration:load', { detail: { ...event.detail } }); document.dispatchEvent(delayedEvent); + + initializeTooltips(); }); } @@ -53,3 +57,7 @@ const flushQueue = (queue) => { queue.forEach(forwardTurboLoad); queue.length = 0; }; + +document.addEventListener('turbo:visit', (event) => { + destroyTooltips(); +}) From 84756bef08d0667efdabbd738c319e8d04731d2b Mon Sep 17 00:00:00 2001 From: Sebastian Serth Date: Sun, 29 Jun 2025 23:45:52 +0200 Subject: [PATCH 11/14] Install event listener only once --- app/assets/javascripts/nbp_wallet.js | 20 ++++++++++---------- app/assets/javascripts/tasks_index.coffee | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/app/assets/javascripts/nbp_wallet.js b/app/assets/javascripts/nbp_wallet.js index 3490c2b9d..f8bfc05f1 100644 --- a/app/assets/javascripts/nbp_wallet.js +++ b/app/assets/javascripts/nbp_wallet.js @@ -26,16 +26,6 @@ const countdownValidity = () => { } }; -window.addEventListener("turbo:visit", () => { - clearInterval(intervalID); - clearTimeout(timeoutID); -}); - -window.addEventListener("beforeunload", () => { - clearInterval(intervalID); - clearTimeout(timeoutID); -}); - $(document).on('turbo-migration:load', function () { if (window.location.pathname !== Routes.nbp_wallet_connect_users_path()) { return; @@ -49,4 +39,14 @@ $(document).on('turbo-migration:load', function () { templateValidity = document.querySelector('[data-id="nbp_wallet_qr_code"]').dataset.remainingValidity - 5; checkStatus(); intervalID = setInterval(countdownValidity, 1000); + + $(document).one("turbo:visit", () => { + clearInterval(intervalID); + clearTimeout(timeoutID); + }); + + $(window).one("beforeunload", () => { + clearInterval(intervalID); + clearTimeout(timeoutID); + }); }); diff --git a/app/assets/javascripts/tasks_index.coffee b/app/assets/javascripts/tasks_index.coffee index b4e979817..7bc2384a6 100644 --- a/app/assets/javascripts/tasks_index.coffee +++ b/app/assets/javascripts/tasks_index.coffee @@ -1,6 +1,5 @@ ready = -> initCollapsable($('.description'), '95px') - window.addEventListener 'resize', -> initCollapsable($('.description'), '95px') initializeDynamicHideShow() initializeFilter() initializeIndexComments() @@ -140,4 +139,5 @@ initializeInputFieldEnterCallback = -> $(document).on('turbo-migration:load', ready) +$(window).on('resize', -> initCollapsable($('.description'), '95px')) $(document).on('select2:locales:loaded', initializeSelect2) From 7d5820c35a60d3df05c22c4b5bd53df323c5108a Mon Sep 17 00:00:00 2001 From: Sebastian Serth Date: Wed, 2 Jul 2025 12:27:27 +0200 Subject: [PATCH 12/14] JS Performance: Unload and destroy ACE editor on navigation With this change, each editor instance is destroyed upon changing the page. This prevents memory leaks and thus optimizes the performance. The editor will be restored upon visiting the sites again. --- app/assets/javascripts/editor.coffee | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/assets/javascripts/editor.coffee b/app/assets/javascripts/editor.coffee index 58a353174..9332e1ad0 100644 --- a/app/assets/javascripts/editor.coffee +++ b/app/assets/javascripts/editor.coffee @@ -6,6 +6,14 @@ initializeAce = -> $(document).on 'fields_added.nested_form_fields', -> $ initializeEditors() +unloadEditors = (event) -> + $('.editor').each (_, editor) -> + aceEditor = ace.edit(editor) + content = aceEditor.getValue(); + aceEditor.destroy() + editor.textContent = content; + return + initializeEditors = -> $('.editor').each -> editor = ace.edit(this) @@ -17,6 +25,8 @@ initializeEditors = -> else editor.getSession().on 'change', (e) -> hiddenContent.val(editor.getValue()).trigger('change') + $(document).one('turbo:visit', unloadEditors); + $(window).one('beforeunload', unloadEditors); setAceEditorValue = (editor, value) -> aceEditor = $(editor).parent().find('.editor')[0] From 0d5ece951f3df0d7f67f8ae2b95ed142e861cdce Mon Sep 17 00:00:00 2001 From: Sebastian Serth Date: Wed, 2 Jul 2025 12:28:04 +0200 Subject: [PATCH 13/14] JS Performance: Unload Select2 on navigation Similar to the ACE editor, we unload Select2 to prevent memory leaks and duplicated selectors when navigating to a cached site. --- app/assets/javascripts/labels.coffee | 7 +++++++ app/assets/javascripts/tasks_form.coffee | 6 ++++++ app/assets/javascripts/tasks_index.coffee | 7 +++++++ 3 files changed, 20 insertions(+) diff --git a/app/assets/javascripts/labels.coffee b/app/assets/javascripts/labels.coffee index e5edb9e00..bd6170545 100644 --- a/app/assets/javascripts/labels.coffee +++ b/app/assets/javascripts/labels.coffee @@ -97,12 +97,19 @@ ready = -> I18n.t('labels.javascripts.max_limit_reached', {limit: limit}) $('.labels-select2-tag').on 'select2:select', clear_input + $(document).one('turbo:visit', destroy_select2); return clear_input = -> $('.labels-select2-tag').siblings(".select2").find("textarea").val(""); return +destroy_select2 = -> + selectElement = $('.labels-select2-tag'); + selectElement.select2('destroy'); + selectElement.off('select2:select'); + return + root = exports ? this; root.verify_label_name = verify_label_name; diff --git a/app/assets/javascripts/tasks_form.coffee b/app/assets/javascripts/tasks_form.coffee index 855eb71a0..8713dcac9 100644 --- a/app/assets/javascripts/tasks_form.coffee +++ b/app/assets/javascripts/tasks_form.coffee @@ -16,6 +16,8 @@ initializeLoadSelect2 = -> closeOnSelect: false placeholder: I18n.t('tasks.javascripts.select_groups') + $(document).one('turbo:visit', destroy_select2); + initializeFileTypeSelection = -> $('body').on 'keyup', '.file-name', (event) -> editor = $(this).parents('.file-container').find('.editor')[0] @@ -30,6 +32,10 @@ initializeVisibilityWarning = -> $('#task_access_level_public').on 'change', -> warning_message.addClass('d-none') +destroy_select2 = -> + $('#task_programming_language_id').select2('destroy'); + $('.my-group').select2('destroy'); + return $(document).on('turbo-migration:load', ready) $(document).on('select2:locales:loaded', initializeLoadSelect2) diff --git a/app/assets/javascripts/tasks_index.coffee b/app/assets/javascripts/tasks_index.coffee index 7bc2384a6..a6ffabff4 100644 --- a/app/assets/javascripts/tasks_index.coffee +++ b/app/assets/javascripts/tasks_index.coffee @@ -24,6 +24,8 @@ initializeSelect2 = -> closeOnSelect: false placeholder: I18n.t('tasks.javascripts.all_languages') + $(document).one('turbo:visit', destroy_select2); + toggleHideShowMore = (element) -> $parent = $(element).parent() $toggle = $(element).find '.more-btn' @@ -137,6 +139,11 @@ initializeInputFieldEnterCallback = -> $('.search-submit-button-tag').click(); event.preventDefault(); +destroy_select2 = -> + $('.defaultSelect2').select2('destroy'); + $('.language-box').select2('destroy'); + return + $(document).on('turbo-migration:load', ready) $(window).on('resize', -> initCollapsable($('.description'), '95px')) From 4db7b148fa288b84327528ef0b9ed4522b6756eb Mon Sep 17 00:00:00 2001 From: Sebastian Serth Date: Sun, 29 Jun 2025 18:36:33 +0200 Subject: [PATCH 14/14] Use Firefox for system specs in GitHub Actions In general, we can run system specs with four browser combinations: 1. Chrome 2. Chrome Headless 3. Firefox 4. Firefox Headless Unfortunately, Selenium has an ongoing issue with Chrome Headless and Alerts, making the test execution slow or causing random failures. Non-headless Chrome works fine. To overcome these issues, we switch to Firefox for the time being in GitHub Actions. Non-headless browsers are not supported in GitHub Actions. --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 188365eb0..599eae3b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,6 +79,9 @@ jobs: env: RAILS_ENV: test CC_TEST_REPORTER_ID: true + # Use Firefox for system tests until Chrome headless works reliably again. + # See https://github.com/SeleniumHQ/selenium/issues/15273 + BROWSER: firefox run: bundle exec rspec --color --format RSpec::Github::Formatter --format progress - name: Upload coverage reports to Codecov