From d191b7eb9da29fc24562a8c12d76c00e3956cba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Wed, 2 Mar 2022 00:26:40 +0100 Subject: [PATCH] Set up confirmation modals (#1033) * Set up confirmation modals * Add temporary fix for the global hook remount --- assets/css/utilities.css | 4 +- assets/js/app.js | 2 + assets/js/confirm_modal/index.js | 63 ++++++++ assets/js/lib/settings.js | 22 +-- assets/js/lib/storage.js | 34 ++++ lib/livebook_web/helpers.ex | 148 ++++++++++++++++-- lib/livebook_web/live/home_live.ex | 13 +- .../live/home_live/close_session_component.ex | 2 +- lib/livebook_web/live/session_live.ex | 27 ++-- .../live/session_live/cell_component.ex | 12 +- lib/livebook_web/live/settings_live.ex | 19 +-- .../settings_live/file_systems_component.ex | 15 +- .../remove_file_system_component.ex | 33 ---- lib/livebook_web/live/sidebar_helpers.ex | 14 +- lib/livebook_web/live/user_helpers.ex | 2 +- lib/livebook_web/router.ex | 1 - .../templates/layout/live.html.heex | 2 + 17 files changed, 305 insertions(+), 108 deletions(-) create mode 100644 assets/js/confirm_modal/index.js create mode 100644 assets/js/lib/storage.js delete mode 100644 lib/livebook_web/live/settings_live/remove_file_system_component.ex diff --git a/assets/css/utilities.css b/assets/css/utilities.css index 48712ee41..4826cc5bc 100644 --- a/assets/css/utilities.css +++ b/assets/css/utilities.css @@ -34,11 +34,11 @@ /* Animations */ .fade-in { - animation: fade-in-frames 200ms; + animation: fade-in-frames 200ms ease-out; } .fade-out { - animation: fade-out-frames 200ms; + animation: fade-out-frames 200ms ease-in; } @keyframes fade-in-frames { diff --git a/assets/js/app.js b/assets/js/app.js index 1dfa5a7dc..4dff8fa17 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -25,6 +25,7 @@ import Highlight from "./highlight"; import DragAndDrop from "./drag_and_drop"; import PasswordToggle from "./password_toggle"; import KeyboardControl from "./keyboard_control"; +import ConfirmModal from "./confirm_modal"; import morphdomCallbacks from "./morphdom_callbacks"; import JSView from "./js_view"; import { loadUserData } from "./lib/user"; @@ -46,6 +47,7 @@ const hooks = { PasswordToggle, KeyboardControl, JSView, + ConfirmModal, }; const csrfToken = document diff --git a/assets/js/confirm_modal/index.js b/assets/js/confirm_modal/index.js new file mode 100644 index 000000000..4e52bf812 --- /dev/null +++ b/assets/js/confirm_modal/index.js @@ -0,0 +1,63 @@ +import { load, store } from "../lib/storage"; + +const OPT_OUT_IDS_KEY = "confirm-opted-out-ids"; + +const ConfirmModal = { + mounted() { + let confirmEvent = null; + + const titleEl = this.el.querySelector("[data-title]"); + const descriptionEl = this.el.querySelector("[data-description]"); + const confirmIconEl = this.el.querySelector("[data-confirm-icon]"); + const confirmTextEl = this.el.querySelector("[data-confirm-text]"); + const optOutEl = this.el.querySelector("[data-opt-out]"); + const optOutElCheckbox = optOutEl.querySelector("input"); + + const optedOutIds = load(OPT_OUT_IDS_KEY) || []; + + this.handleConfirmRequest = (event) => { + confirmEvent = event; + + const { title, description, confirm_text, confirm_icon, opt_out_id } = + event.detail; + + if (opt_out_id && optedOutIds.includes(opt_out_id)) { + liveSocket.execJS(event.target, event.detail.on_confirm); + } else { + titleEl.textContent = title; + descriptionEl.textContent = description; + confirmTextEl.textContent = confirm_text; + + if (confirm_icon) { + confirmIconEl.className = `align-middle mr-1 ri-${confirm_icon}`; + } else { + confirmIconEl.className = "hidden"; + } + + optOutElCheckbox.checked = false; + optOutEl.classList.toggle("hidden", !opt_out_id); + + liveSocket.execJS(this.el, this.el.getAttribute("data-js-show")); + } + }; + + window.addEventListener("lb:confirm_request", this.handleConfirmRequest); + + this.el.addEventListener("lb:confirm", (event) => { + const { opt_out_id } = confirmEvent.detail; + + if (opt_out_id && optOutElCheckbox.checked) { + optedOutIds.push(opt_out_id); + store(OPT_OUT_IDS_KEY, optedOutIds); + } + + liveSocket.execJS(confirmEvent.target, confirmEvent.detail.on_confirm); + }); + }, + + destroyed() { + window.removeEventListener("lb:confirm_request", this.handleConfirmRequest); + }, +}; + +export default ConfirmModal; diff --git a/assets/js/lib/settings.js b/assets/js/lib/settings.js index e6af49461..15d941447 100644 --- a/assets/js/lib/settings.js +++ b/assets/js/lib/settings.js @@ -1,4 +1,6 @@ -const SETTINGS_KEY = "livebook:settings"; +import { load, store } from "./storage"; + +const SETTINGS_KEY = "settings"; export const EDITOR_FONT_SIZE = { normal: 14, @@ -61,25 +63,15 @@ class SettingsStore { } _loadSettings() { - try { - const json = localStorage.getItem(SETTINGS_KEY); + const settings = load(SETTINGS_KEY); - if (json) { - const settings = JSON.parse(json); - this._settings = { ...this._settings, ...settings }; - } - } catch (error) { - console.error(`Failed to load local settings, reason: ${error.message}`); + if (settings) { + this._settings = { ...this._settings, ...settings }; } } _storeSettings() { - try { - const json = JSON.stringify(this._settings); - localStorage.setItem(SETTINGS_KEY, json); - } catch (error) { - console.error(`Failed to store local settings, reason: ${error.message}`); - } + store(SETTINGS_KEY, this._settings); } } diff --git a/assets/js/lib/storage.js b/assets/js/lib/storage.js new file mode 100644 index 000000000..e121b04e8 --- /dev/null +++ b/assets/js/lib/storage.js @@ -0,0 +1,34 @@ +const PREFIX = "livebook:"; + +/** + * Loads value from local storage. + */ +export function load(key) { + try { + const json = localStorage.getItem(PREFIX + key); + + if (json) { + return JSON.parse(json); + } + } catch (error) { + console.error( + `Failed to load from local storage, reason: ${error.message}` + ); + } + + return undefined; +} + +/** + * Stores value in local storage. + * + * The value is serialized as JSON. + */ +export function store(key, value) { + try { + const json = JSON.stringify(value); + localStorage.setItem(PREFIX + key, json); + } catch (error) { + console.error(`Failed to write to local storage, reason: ${error.message}`); + } +} diff --git a/lib/livebook_web/helpers.ex b/lib/livebook_web/helpers.ex index 197457f65..c1022b995 100644 --- a/lib/livebook_web/helpers.ex +++ b/lib/livebook_web/helpers.ex @@ -10,44 +10,48 @@ defmodule LivebookWeb.Helpers do @doc """ Wraps the given content in a modal dialog. - When closed, the modal redirects to the given `:return_to` URL. - ## Example - <.modal return_to={...}> + <.modal id="edit-modal" patch={...}> <.live_component module={MyComponent} /> """ def modal(assigns) do assigns = assigns + |> assign_new(:show, fn -> false end) + |> assign_new(:patch, fn -> nil end) + |> assign_new(:navigate, fn -> nil end) |> assign_new(:class, fn -> "" end) + |> assign(:attrs, assigns_to_attributes(assigns, [:id, :show, :patch, :navigate, :class])) ~H""" -
+
- - +
@@ -55,8 +59,118 @@ defmodule LivebookWeb.Helpers do """ end - defp click_modal_close(js \\ %JS{}) do - JS.dispatch(js, "click", to: "#close-modal-button") + @doc """ + Shows a modal rendered with `modal/1`. + """ + def show_modal(js \\ %JS{}, id) do + js + |> JS.show( + to: "##{id}", + transition: {"ease-out duration-200", "opacity-0", "opacity-100"} + ) + end + + @doc """ + Hides a modal rendered with `modal/1`. + """ + def hide_modal(js \\ %JS{}, id) do + js + |> JS.hide( + to: "##{id}", + transition: {"ease-in duration-200", "opacity-100", "opacity-0"} + ) + |> JS.dispatch("click", to: "##{id}-return") + end + + @doc """ + Renders the confirmation modal for `with_confirm/3`. + """ + def confirm_modal(assigns) do + # TODO: this ensures unique ids when navigating across LVs. + # Remove once https://github.com/phoenixframework/phoenix_live_view/issues/1903 + # is resolved + lv_id = self() |> :erlang.term_to_binary() |> Base.encode32() + assigns = assign_new(assigns, :id, fn -> "confirm-modal-#{lv_id}" end) + + ~H""" + <.modal id={@id} class="w-full max-w-xl" phx-hook="ConfirmModal" data-js-show={show_modal(@id)}> +
+

+

+ +
+ + +
+
+ + """ + end + + @doc """ + Shows a confirmation modal before executing the given JS action. + + The modal template must already be on the page, see `confirm_modal/1`. + + ## Options + + * `:title` - title of the confirmation modal. Defaults to `"Are you sure?"` + + * `:description` - content of the confirmation modal. Required + + * `:confirm_text` - text of the confirm button. Defaults to `"Yes"` + + * `:confirm_icon` - icon in the confirm button. Optional + + * `:opt_out_id` - enables the "Don't show this message again" + checkbox. Once checked by the user, the confirmation with this + id is never shown again. Optional + + ## Examples + + + """ + def with_confirm(js \\ %JS{}, on_confirm, opts) do + opts = + Keyword.validate!( + opts, + [:confirm_icon, :description, :opt_out_id, title: "Are you sure?", confirm_text: "Yes"] + ) + + JS.dispatch(js, "lb:confirm_request", + detail: %{ + on_confirm: Jason.encode!(on_confirm.ops), + title: opts[:title], + description: Keyword.fetch!(opts, :description), + confirm_text: opts[:confirm_text], + confirm_icon: opts[:confirm_icon], + opt_out_id: opts[:opt_out_id] + } + ) end @doc """ diff --git a/lib/livebook_web/live/home_live.ex b/lib/livebook_web/live/home_live.ex index 9c30cb4b1..ae9de5d5d 100644 --- a/lib/livebook_web/live/home_live.ex +++ b/lib/livebook_web/live/home_live.ex @@ -20,6 +20,7 @@ defmodule LivebookWeb.HomeLive do socket |> SidebarHelpers.shared_home_handlers() |> assign( + self_path: Routes.home_path(socket, :page), file: determine_file(params), file_info: %{exists: true, access: :read_write}, sessions: sessions, @@ -117,21 +118,21 @@ defmodule LivebookWeb.HomeLive do <%= if @live_action == :user do %> <.current_user_modal - return_to={Routes.home_path(@socket, :page)} + return_to={@self_path} current_user={@current_user} /> <% end %> <%= if @live_action == :close_session do %> - <.modal class="w-full max-w-xl" return_to={Routes.home_path(@socket, :page)}> + <.modal id="close-session-modal" show class="w-full max-w-xl" patch={@self_path}> <.live_component module={LivebookWeb.HomeLive.CloseSessionComponent} id="close-session" - return_to={Routes.home_path(@socket, :page)} + return_to={@self_path} session={@session} /> <% end %> <%= if @live_action == :import do %> - <.modal class="w-full max-w-xl" return_to={Routes.home_path(@socket, :page)}> + <.modal id="import-modal" show class="w-full max-w-xl" patch={@self_path}> <.live_component module={LivebookWeb.HomeLive.ImportComponent} id="import" tab={@tab} @@ -140,11 +141,11 @@ defmodule LivebookWeb.HomeLive do <% end %> <%= if @live_action == :edit_sessions do %> - <.modal class="w-full max-w-xl" return_to={Routes.home_path(@socket, :page)}> + <.modal id="edit-sessions-modal" show class="w-full max-w-xl" patch={@self_path}> <.live_component module={LivebookWeb.HomeLive.EditSessionsComponent} id="edit-sessions" action={@bulk_action} - return_to={Routes.home_path(@socket, :page)} + return_to={@self_path} sessions={@sessions} selected_sessions={selected_sessions(@sessions, @selected_session_ids)} /> diff --git a/lib/livebook_web/live/home_live/close_session_component.ex b/lib/livebook_web/live/home_live/close_session_component.ex index 3d147ee69..71aba2096 100644 --- a/lib/livebook_web/live/home_live/close_session_component.ex +++ b/lib/livebook_web/live/home_live/close_session_component.ex @@ -11,7 +11,7 @@ defmodule LivebookWeb.HomeLive.CloseSessionComponent do Close session

- Are you sure you want to close this section - + Are you sure you want to close this session - “<%= @session.notebook_name %>”?
<%= if @session.file, diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index 35360f9b2..9c99e4e40 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -47,6 +47,7 @@ defmodule LivebookWeb.SessionLive do {:ok, socket |> assign( + self_path: Routes.session_path(socket, :page, session.id), session: session, platform: platform, self: self(), @@ -227,12 +228,12 @@ defmodule LivebookWeb.SessionLive do <%= if @live_action == :user do %> <.current_user_modal - return_to={Routes.session_path(@socket, :page, @session.id)} + return_to={@self_path} current_user={@current_user} /> <% end %> <%= if @live_action == :runtime_settings do %> - <.modal class="w-full max-w-4xl" return_to={Routes.session_path(@socket, :page, @session.id)}> + <.modal id="runtime-settings-modal" show class="w-full max-w-4xl" patch={@self_path}> <.live_component module={LivebookWeb.SessionLive.RuntimeComponent} id="runtime-settings" session={@session} @@ -241,7 +242,7 @@ defmodule LivebookWeb.SessionLive do <% end %> <%= if @live_action == :file_settings do %> - <.modal class="w-full max-w-4xl" return_to={Routes.session_path(@socket, :page, @session.id)}> + <.modal id="persistence-modal" show class="w-full max-w-4xl" patch={@self_path}> <%= live_render @socket, LivebookWeb.SessionLive.PersistenceLive, id: "persistence", session: %{ @@ -254,7 +255,7 @@ defmodule LivebookWeb.SessionLive do <% end %> <%= if @live_action == :shortcuts do %> - <.modal class="w-full max-w-6xl" return_to={Routes.session_path(@socket, :page, @session.id)}> + <.modal id="shortcuts-modal" show class="w-full max-w-6xl" patch={@self_path}> <.live_component module={LivebookWeb.SessionLive.ShortcutsComponent} id="shortcuts" platform={@platform} /> @@ -262,49 +263,49 @@ defmodule LivebookWeb.SessionLive do <% end %> <%= if @live_action == :cell_settings do %> - <.modal class="w-full max-w-xl" return_to={Routes.session_path(@socket, :page, @session.id)}> + <.modal id="cell-settings-modal" show class="w-full max-w-xl" patch={@self_path}> <.live_component module={settings_component_for(@cell)} id="cell-settings" session={@session} - return_to={Routes.session_path(@socket, :page, @session.id)} + return_to={@self_path} cell={@cell} /> <% end %> <%= if @live_action == :cell_upload do %> - <.modal class="w-full max-w-xl" return_to={Routes.session_path(@socket, :page, @session.id)}> + <.modal id="cell-upload-modal" show class="w-full max-w-xl" patch={@self_path}> <.live_component module={LivebookWeb.SessionLive.CellUploadComponent} id="cell-upload" session={@session} - return_to={Routes.session_path(@socket, :page, @session.id)} + return_to={@self_path} cell={@cell} uploads={@uploads} /> <% end %> <%= if @live_action == :delete_section do %> - <.modal class="w-full max-w-xl" return_to={Routes.session_path(@socket, :page, @session.id)}> + <.modal id="delete-section-modal" show class="w-full max-w-xl" patch={@self_path}> <.live_component module={LivebookWeb.SessionLive.DeleteSectionComponent} id="delete-section" session={@session} - return_to={Routes.session_path(@socket, :page, @session.id)} + return_to={@self_path} section={@section} is_first={@section.id == @first_section_id} /> <% end %> <%= if @live_action == :bin do %> - <.modal class="w-full max-w-4xl" return_to={Routes.session_path(@socket, :page, @session.id)}> + <.modal id="bin-modal" show class="w-full max-w-4xl" patch={@self_path}> <.live_component module={LivebookWeb.SessionLive.BinComponent} id="bin" session={@session} - return_to={Routes.session_path(@socket, :page, @session.id)} + return_to={@self_path} bin_entries={@data_view.bin_entries} /> <% end %> <%= if @live_action == :export do %> - <.modal class="w-full max-w-4xl" return_to={Routes.session_path(@socket, :page, @session.id)}> + <.modal id="export-modal" show class="w-full max-w-4xl" patch={@self_path}> <.live_component module={LivebookWeb.SessionLive.ExportComponent} id="export" session={@session} diff --git a/lib/livebook_web/live/session_live/cell_component.ex b/lib/livebook_web/live/session_live/cell_component.ex index f468e38be..eb361c05f 100644 --- a/lib/livebook_web/live/session_live/cell_component.ex +++ b/lib/livebook_web/live/session_live/cell_component.ex @@ -317,8 +317,16 @@ defmodule LivebookWeb.SessionLive.CellComponent do diff --git a/lib/livebook_web/live/settings_live.ex b/lib/livebook_web/live/settings_live.ex index e825d2080..057af9761 100644 --- a/lib/livebook_web/live/settings_live.ex +++ b/lib/livebook_web/live/settings_live.ex @@ -122,22 +122,12 @@ defmodule LivebookWeb.SettingsLive do <% end %> <%= if @live_action == :add_file_system do %> - <.modal class="w-full max-w-3xl" return_to={Routes.settings_path(@socket, :page)}> + <.modal id="add-file-system-modal" show class="w-full max-w-3xl" patch={Routes.settings_path(@socket, :page)}> <.live_component module={LivebookWeb.SettingsLive.AddFileSystemComponent} id="add-file-system" return_to={Routes.settings_path(@socket, :page)} /> <% end %> - - <%= if @live_action == :detach_file_system do %> - <.modal class="w-full max-w-xl" return_to={Routes.settings_path(@socket, :page)}> - <.live_component module={LivebookWeb.SettingsLive.RemoveFileSystemComponent} - id="detach-file-system" - return_to={Routes.settings_path(@socket, :page)} - file_system_id={@file_system_id} - /> - - <% end %> """ end @@ -148,6 +138,13 @@ defmodule LivebookWeb.SettingsLive do def handle_params(_params, _url, socket), do: {:noreply, socket} + @impl true + def handle_event("detach_file_system", %{"id" => file_system_id}, socket) do + Livebook.Settings.remove_file_system(file_system_id) + file_systems = Livebook.Settings.file_systems() + {:noreply, assign(socket, file_systems: file_systems)} + end + @impl true def handle_info({:file_systems_updated, file_systems}, socket) do {:noreply, assign(socket, file_systems: file_systems)} diff --git a/lib/livebook_web/live/settings_live/file_systems_component.ex b/lib/livebook_web/live/settings_live/file_systems_component.ex index a13be0a5c..f9ee71c7b 100644 --- a/lib/livebook_web/live/settings_live/file_systems_component.ex +++ b/lib/livebook_web/live/settings_live/file_systems_component.ex @@ -14,9 +14,18 @@ defmodule LivebookWeb.SettingsLive.FileSystemsComponent do <.file_system_info file_system={file_system} />

<%= unless is_struct(file_system, FileSystem.Local) do %> - <%= live_patch "Detach", - to: Routes.settings_path(@socket, :detach_file_system, file_system_id), - class: "button-base button-outlined-red" %> + <% end %>
<% end %> diff --git a/lib/livebook_web/live/settings_live/remove_file_system_component.ex b/lib/livebook_web/live/settings_live/remove_file_system_component.ex deleted file mode 100644 index 240883f38..000000000 --- a/lib/livebook_web/live/settings_live/remove_file_system_component.ex +++ /dev/null @@ -1,33 +0,0 @@ -defmodule LivebookWeb.SettingsLive.RemoveFileSystemComponent do - use LivebookWeb, :live_component - - @impl true - def render(assigns) do - ~H""" -
-

- Detach file system -

-

- Are you sure you want to detach this file system? - Any sessions using it will keep the access until - they get closed. -

-
- - <%= live_patch "Cancel", to: @return_to, class: "button-base button-outlined-gray" %> -
-
- """ - end - - @impl true - def handle_event("detach", %{}, socket) do - Livebook.Settings.remove_file_system(socket.assigns.file_system_id) - send(self(), {:file_systems_updated, Livebook.Settings.file_systems()}) - {:noreply, push_patch(socket, to: socket.assigns.return_to)} - end -end diff --git a/lib/livebook_web/live/sidebar_helpers.ex b/lib/livebook_web/live/sidebar_helpers.ex index 072875115..d952232ca 100644 --- a/lib/livebook_web/live/sidebar_helpers.ex +++ b/lib/livebook_web/live/sidebar_helpers.ex @@ -4,6 +4,7 @@ defmodule LivebookWeb.SidebarHelpers do import LivebookWeb.Helpers import LivebookWeb.UserHelpers + alias Phoenix.LiveView.JS alias LivebookWeb.Router.Helpers, as: Routes @doc """ @@ -60,9 +61,16 @@ defmodule LivebookWeb.SidebarHelpers do """ diff --git a/lib/livebook_web/live/user_helpers.ex b/lib/livebook_web/live/user_helpers.ex index f42824a07..3edd9c63f 100644 --- a/lib/livebook_web/live/user_helpers.ex +++ b/lib/livebook_web/live/user_helpers.ex @@ -47,7 +47,7 @@ defmodule LivebookWeb.UserHelpers do """ def current_user_modal(assigns) do ~H""" - + <.live_component module={LivebookWeb.UserComponent} id="user" return_to={@return_to} diff --git a/lib/livebook_web/router.ex b/lib/livebook_web/router.ex index 1a0ac8ca3..3a471cd78 100644 --- a/lib/livebook_web/router.ex +++ b/lib/livebook_web/router.ex @@ -52,7 +52,6 @@ defmodule LivebookWeb.Router do live "/settings", SettingsLive, :page live "/settings/user-profile", SettingsLive, :user live "/settings/add-file-system", SettingsLive, :add_file_system - live "/settings/detach-file-system/:file_system_id", SettingsLive, :detach_file_system live "/explore", ExploreLive, :page live "/explore/user-profile", ExploreLive, :user diff --git a/lib/livebook_web/templates/layout/live.html.heex b/lib/livebook_web/templates/layout/live.html.heex index 707db4742..ae028a79b 100644 --- a/lib/livebook_web/templates/layout/live.html.heex +++ b/lib/livebook_web/templates/layout/live.html.heex @@ -31,3 +31,5 @@ <%= @inner_content %> + +<.confirm_modal />