diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 276ef7d8cd0c..609198f726f0 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -52,6 +52,7 @@ class ApplicationController < ActionController::Base include AdditionalUrlHelpers include OpenProjectErrorHelper include Security::DefaultUrlOptions + include OpModalFlashable layout "base" diff --git a/app/controllers/concerns/op_modal_flashable.rb b/app/controllers/concerns/op_modal_flashable.rb new file mode 100644 index 000000000000..9d8d36ff88d7 --- /dev/null +++ b/app/controllers/concerns/op_modal_flashable.rb @@ -0,0 +1,47 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpModalFlashable + extend ActiveSupport::Concern + + included do + add_flash_types :op_modal + end + + def flash_op_modal(component:, parameters: {}) + flash[:op_modal] = { component: component.name, parameters: } + end + + def store_callback_op_modal_flash(component:, parameters: {}) + session[:callback_op_modal] = { component: component.name, parameters: } + end + + def retrieve_callback_op_modal_flash + session.delete(:callback_op_modal) if session[:callback_op_modal].present? + end +end diff --git a/app/controllers/oauth_clients_controller.rb b/app/controllers/oauth_clients_controller.rb index 4329c8c0ebe4..365fefee1711 100644 --- a/app/controllers/oauth_clients_controller.rb +++ b/app/controllers/oauth_clients_controller.rb @@ -56,10 +56,9 @@ def callback service_result = @connection_manager.code_to_token(@code) if service_result.success? - flash[:modal] = session.delete(:oauth_callback_flash_modal) if session[:oauth_callback_flash_modal].present? # Redirect the user to the page that initially wanted to access the OAuth2 resource. # "state" is a nonce that identifies a cookie which holds that page's URL. - redirect_to @redirect_uri + redirect_to @redirect_uri, op_modal: retrieve_callback_op_modal_flash else # We got a list of errors from ::OAuthClients::ConnectionManager set_oauth_errors(service_result) diff --git a/app/helpers/flash_messages_helper.rb b/app/helpers/flash_messages_helper.rb index fa02f49912c5..40300ae197cb 100644 --- a/app/helpers/flash_messages_helper.rb +++ b/app/helpers/flash_messages_helper.rb @@ -38,7 +38,7 @@ module FlashMessagesHelper def render_flash_messages messages = flash .reject { |k, _| k.start_with? "_" } - .reject { |k, _| k.to_s == "modal" } + .reject { |k, _| k.to_s == "op_modal" } .map { |k, v| render_flash_content(k.to_sym, v) } safe_join messages, "\n" @@ -54,10 +54,11 @@ def render_flash_content(key, content) end def render_flash_modal - content = flash[:modal] - return if content.blank? + return if (content = flash[:op_modal]).blank? + + component = content[:component] + component = component.constantize if component.is_a?(String) - component = content[:type].constantize component.new(**content.fetch(:parameters, {})).render_in(self) end diff --git a/lookbook/docs/patterns/09-flash-modal.md.erb b/lookbook/docs/patterns/09-flash-modal.md.erb new file mode 100644 index 000000000000..8bdb82a18082 --- /dev/null +++ b/lookbook/docs/patterns/09-flash-modal.md.erb @@ -0,0 +1,60 @@ +This pattern defines use cases where a modal view component is rendered on the next request via a [flash](https://api.rubyonrails.org/classes/ActionDispatch/Flash.html +). This is useful when you want to display a modal after a user action, such as a form submission or on a callback request from an external service. + +> *_[Flash](https://api.rubyonrails.org/classes/ActionDispatch/Flash.html) provides a way to pass temporary primitive types between controller actions. Anything you place in the flash is stored in the session and will be exposed to the very next action and then cleared out._ + +## Overview + +To flash a modal, set a `flash[:op_modal]` hash in your controller action. The hash should contain the following keys: + * `component:` - the modal component to render + * `parameters:` - the parameters to pass to the modal component + +You can use the helper method `flash_op_modal` to set the flash modal properties. + +Ex. + +```ruby +flash_op_modal component: ::Storages::ProjectStorages::OAuthAccessGrantedModalComponent, + parameters: { storage: storage.id } +``` + +Internally, the `FlashMessagesHelper#render_flash_modal` will select flash messages with the key `:op_modal` and render the modal component with the given parameters. + +P.S. When the `component` is a `Primer::Alpha::Dialog` the `auto-show-dialog` stimulus controller should be declared in order to automatically show the dialog when the page is loaded. + +Ex. + +```ruby +render( + Primer::Alpha::Dialog.new( + id: dialog_id, + title:, + data: { + controller: "auto-show-dialog", + } +) do + # .... +end +``` + +### Flash a modal on callback from external service + +When you want to display a modal after a callback request from an external service, store the op modal component directly in the `session` in the controller action that performs by calling `OpModalFlashable#store_callback_op_modal_flash` bofore the open redirect and then extract them in the controller action that handles the callback via `OpTurbo::Flashable#retrieve_callback_op_modal_flash`. + +Ex. + +```ruby +# Controller action that performs the open redirect +def redirect_to_external_service + store_callback_op_modal_flash component: ::Storages::ProjectStorages::OAuthAccessGrantedModalComponent, + parameters: { storage: storage.id } + redirect_to external_service_url +end + +# Controller action that handles the callback +def callback_from_external_service + # Handle the callback + redirect_to some_path, op_modal: retrieve_callback_op_modal_flash +end +``` + diff --git a/modules/storages/app/controllers/concerns/storages/oauth_access_grantable.rb b/modules/storages/app/controllers/concerns/storages/oauth_access_grantable.rb index 64e6830124c9..cbee6317cde6 100644 --- a/modules/storages/app/controllers/concerns/storages/oauth_access_grantable.rb +++ b/modules/storages/app/controllers/concerns/storages/oauth_access_grantable.rb @@ -38,12 +38,12 @@ def open_redirect_to_storage_authorization_with(callback_url:, storage:, callbac expires: 1.hour } - session[:oauth_callback_flash_modal] = case callback_modal_for - when :storage - storage_oauth_access_granted_modal(storage:) - when :project_storage - project_storage_oauth_access_granted_modal(storage:) - end + store_callback_op_modal_flash(**(case callback_modal_for + when :storage + storage_oauth_access_granted_modal(storage:) + when :project_storage + project_storage_oauth_access_granted_modal(storage:) + end)) redirect_to(storage.oauth_configuration.authorization_uri(state: nonce), allow_other_host: true) end @@ -54,21 +54,21 @@ def storage_oauth_access_granted?(storage:) def project_storage_oauth_access_grant_nudge_modal(project_storage:) { - type: ::Storages::ProjectStorages::OAuthAccessGrantNudgeModalComponent.name, + component: ::Storages::ProjectStorages::OAuthAccessGrantNudgeModalComponent, parameters: { project_storage: project_storage.id } } end def project_storage_oauth_access_granted_modal(storage:) { - type: ::Storages::ProjectStorages::OAuthAccessGrantedModalComponent.name, + component: ::Storages::ProjectStorages::OAuthAccessGrantedModalComponent, parameters: { storage: storage.id } } end def storage_oauth_access_granted_modal(storage:) { - type: ::Storages::Admin::Storages::OAuthAccessGrantedModalComponent.name, + component: ::Storages::Admin::Storages::OAuthAccessGrantedModalComponent, parameters: { storage: storage.id } } end diff --git a/modules/storages/app/controllers/storages/admin/project_storages_controller.rb b/modules/storages/app/controllers/storages/admin/project_storages_controller.rb index 1dee10947751..d5eece1e70ae 100644 --- a/modules/storages/app/controllers/storages/admin/project_storages_controller.rb +++ b/modules/storages/app/controllers/storages/admin/project_storages_controller.rb @@ -165,7 +165,7 @@ def redirect_to_project_storages_path_with_oauth_access_grant_confirmation def redirect_to_project_storages_path_with_nudge_modal redirect_to( external_file_storages_project_settings_project_storages_path, - flash: { modal: project_storage_oauth_access_grant_nudge_modal(project_storage: @project_storage) } + op_modal: project_storage_oauth_access_grant_nudge_modal(project_storage: @project_storage) ) end end diff --git a/modules/storages/app/controllers/storages/project_storages_controller.rb b/modules/storages/app/controllers/storages/project_storages_controller.rb index cb3b738e4d37..70e53c650c39 100644 --- a/modules/storages/app/controllers/storages/project_storages_controller.rb +++ b/modules/storages/app/controllers/storages/project_storages_controller.rb @@ -118,14 +118,12 @@ def user_can_not_read_project_folder def redirect_to_project_overview_with_modal redirect_to( project_overview_path(project_id: @project.identifier), - flash: { - modal: { - type: "Storages::OpenProjectStorageModalComponent", - parameters: { - project_storage_open_url: request.path, - redirect_url: api_v3_project_storage_open, - state: :waiting - } + op_modal: { + component: Storages::OpenProjectStorageModalComponent.name, + parameters: { + project_storage_open_url: request.path, + redirect_url: api_v3_project_storage_open, + state: :waiting } } ) diff --git a/modules/storages/spec/requests/project_storages_open_spec.rb b/modules/storages/spec/requests/project_storages_open_spec.rb index 4baeb7b0f06c..c4e5099e7e32 100644 --- a/modules/storages/spec/requests/project_storages_open_spec.rb +++ b/modules/storages/spec/requests/project_storages_open_spec.rb @@ -113,8 +113,8 @@ expect(last_response.headers["Location"]).to eq("http://#{Setting.host_name}/projects/#{project.identifier}") expect(last_request.session["flash"]["flashes"]) .to eq({ - "modal" => { - type: "Storages::OpenProjectStorageModalComponent", + "op_modal" => { + component: "Storages::OpenProjectStorageModalComponent", parameters: { project_storage_open_url: "/projects/#{project.identifier}/project_storages/#{project_storage.id}/open", redirect_url: expected_redirect_path, state: :waiting } @@ -144,8 +144,8 @@ expect(last_response.headers["Location"]).to eq("http://#{Setting.host_name}/projects/#{project.identifier}") expect(last_request.session["flash"]["flashes"]) .to eq({ - "modal" => { - type: "Storages::OpenProjectStorageModalComponent", + "op_modal" => { + component: "Storages::OpenProjectStorageModalComponent", parameters: { project_storage_open_url: "/projects/#{project.identifier}/project_storages/#{project_storage.id}/open", redirect_url: expected_redirect_path, state: :waiting }