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 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..ba08ab328 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'), @@ -55,9 +55,6 @@ $(document).on('turbolinks: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/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..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] @@ -34,4 +44,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.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/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..f8bfc05f1 100644 --- a/app/assets/javascripts/nbp_wallet.js +++ b/app/assets/javascripts/nbp_wallet.js @@ -26,17 +26,7 @@ const countdownValidity = () => { } }; -window.addEventListener("turbolinks:before-render", () => { - clearInterval(intervalID); - clearTimeout(timeoutID); -}); - -window.addEventListener("beforeunload", () => { - clearInterval(intervalID); - clearTimeout(timeoutID); -}); - -$(document).on('turbolinks:load', function () { +$(document).on('turbo-migration:load', function () { if (window.location.pathname !== Routes.nbp_wallet_connect_users_path()) { return; } @@ -49,4 +39,14 @@ $(document).on('turbolinks: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/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..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('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..a6ffabff4 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() @@ -25,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' @@ -138,6 +139,12 @@ initializeInputFieldEnterCallback = -> $('.search-submit-button-tag').click(); event.preventDefault(); +destroy_select2 = -> + $('.defaultSelect2').select2('destroy'); + $('.language-box').select2('destroy'); + return + -$(document).on('turbolinks:load', ready) +$(document).on('turbo-migration:load', ready) +$(window).on('resize', -> initCollapsable($('.description'), '95px')) $(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/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/controllers/account_links_controller.rb b/app/controllers/account_links_controller.rb index 0c81faed7..d6411b3ae 100644 --- a/app/controllers/account_links_controller.rb +++ b/app/controllers/account_links_controller.rb @@ -24,10 +24,12 @@ 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 } + format.html { render :new, status: :unprocessable_content } format.json { render json: @account_link.errors, status: :unprocessable_content } end end @@ -36,10 +38,14 @@ 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 } + format.html { render :edit, status: :unprocessable_content } format.json { render json: @account_link.errors, status: :unprocessable_content } end end @@ -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 f895f8e6a..0b93d0d29 100644 --- a/app/controllers/collections_controller.rb +++ b/app/controllers/collections_controller.rb @@ -41,34 +41,34 @@ 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 + render :new, status: :unprocessable_content end end 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 + render :edit, status: :unprocessable_content end end 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 425ec9539..2a5220b59 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -30,31 +30,31 @@ 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 + render :new, status: :unprocessable_content end end 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 + render :edit, status: :unprocessable_content end end 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 55403ae31..a0e3463e3 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -33,9 +33,9 @@ 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 + render :new, status: :unprocessable_content end 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 122071a74..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,20 +79,25 @@ 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' + render 'tasks/new', status: :unprocessable_content end end 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) + 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/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 28e6b981f..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,9 +57,9 @@ 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 + render :new, status: :unprocessable_content end end @@ -67,23 +67,23 @@ 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 + render :edit, status: :unprocessable_content end end 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/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/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 new file mode 100644 index 000000000..ad57ec6d3 --- /dev/null +++ b/app/javascript/turbo-migration.js @@ -0,0 +1,63 @@ +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, +// 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 = []; +const turboRenderQueue = []; + +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); +}); + +// 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 } }); + document.dispatchEvent(delayedEvent); + + initializeTooltips(); + }); +} + +const flushQueue = (queue) => { + queue.forEach(forwardTurboLoad); + queue.length = 0; +}; + +document.addEventListener('turbo:visit', (event) => { + destroyTooltips(); +}) 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/_locale_selector.html.slim b/app/views/application/_locale_selector.html.slim index 0882da2dd..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-turbolinks' => 'false', class: 'dropdown-item') + li = link_to(t("common.locales.#{locale}"), AuthenticatedUrlHelper.add_query_parameters(request.url, locale:), 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/application/_session.html.slim b/app/views/application/_session.html.slim index 3f221aac8..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 = 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', 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 4c335523f..a2881b23a 100644 --- a/app/views/collections/_form.html.slim +++ b/app/views/collections/_form.html.slim @@ -1,11 +1,8 @@ - content_for :head do - // Force a full page reload, see https://github.com/turbolinks/turbolinks/issues/326. - Otherwise, code might not be highlighted correctly (race condition) - meta name='turbolinks-visit-control' content='reload' - 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 @@ -55,7 +52,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/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/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/_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..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://github.com/turbolinks/turbolinks/issues/326. - Otherwise, code might not be highlighted correctly (race condition) - meta name='turbolinks-visit-control' content='reload' - append_javascript_pack_tag('toast-ui') - append_stylesheet_pack_tag('toast-ui') @@ -14,7 +11,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/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 6d2abafff..eb244fd57 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') @@ -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 @@ -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 @@ -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 @@ -587,23 +587,23 @@ .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? - = 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..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') - = 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', data: {turbo: false} = 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/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/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/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/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 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..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 @@ -22,7 +23,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 +44,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 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/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/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 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"