Skip to content

Commit

Permalink
Merge pull request #16856 from opf/task/56496-document-primer-flash-m…
Browse files Browse the repository at this point in the history
…odals-pattern

Task/56496 document flash modals pattern
  • Loading branch information
akabiru authored Oct 1, 2024
2 parents 3ff5fe1 + 095f641 commit 0490391
Show file tree
Hide file tree
Showing 9 changed files with 134 additions and 28 deletions.
1 change: 1 addition & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class ApplicationController < ActionController::Base
include AdditionalUrlHelpers
include OpenProjectErrorHelper
include Security::DefaultUrlOptions
include OpModalFlashable

layout "base"

Expand Down
47 changes: 47 additions & 0 deletions app/controllers/concerns/op_modal_flashable.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 1 addition & 2 deletions app/controllers/oauth_clients_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 5 additions & 4 deletions app/helpers/flash_messages_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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

Expand Down
60 changes: 60 additions & 0 deletions lookbook/docs/patterns/09-flash-modal.md.erb
Original file line number Diff line number Diff line change
@@ -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
```

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
)
Expand Down
8 changes: 4 additions & 4 deletions modules/storages/spec/requests/project_storages_open_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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 }
Expand Down

0 comments on commit 0490391

Please sign in to comment.