Rebuild confirmation modal system (#1903)

This commit is contained in:
Jonatan Kłosko 2023-05-10 18:23:08 +02:00 committed by GitHub
parent ff22d5b31c
commit f262b9c0c9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 567 additions and 521 deletions

View file

@ -18,6 +18,10 @@ import { loadAppAuthToken } from "./lib/app";
import { settingsStore } from "./lib/settings";
import { registerTopbar, registerGlobalEventHandlers } from "./events";
import { cookieOptions } from "./lib/utils";
import {
loadConfirmOptOutIds,
registerGlobalEventHandlersForConfirm,
} from "./confirm";
function connect() {
const csrfToken = document
@ -34,6 +38,7 @@ function connect() {
// Pass the most recent user data to the LiveView in `connect_params`
user_data: loadUserData(),
app_auth_token: loadAppAuthToken(),
confirm_opt_out_ids: loadConfirmOptOutIds(),
};
},
hooks: hooks,
@ -47,6 +52,8 @@ function connect() {
// Handle custom events dispatched with JS.dispatch/3
registerGlobalEventHandlers();
registerGlobalEventHandlersForConfirm();
// Reflect global configuration in attributes to enable CSS rules
settingsStore.getAndSubscribe((settings) => {
document.body.setAttribute("data-editor-theme", settings.editor_theme);

16
assets/js/confirm.js Normal file
View file

@ -0,0 +1,16 @@
import { load, store } from "./lib/storage";
const OPT_OUT_IDS_KEY = "confirm-opted-out-ids";
export function loadConfirmOptOutIds() {
return load(OPT_OUT_IDS_KEY) || [];
}
export function registerGlobalEventHandlersForConfirm() {
window.addEventListener("phx:add_confirm_opt_out_id", (event) => {
const optedOutIds = load(OPT_OUT_IDS_KEY) || [];
const optOutId = event.detail.opt_out_id;
optedOutIds.push(optOutId);
store(OPT_OUT_IDS_KEY, optedOutIds);
});
}

View file

@ -1,98 +0,0 @@
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 confirmButtonEl = this.el.querySelector("[data-confirm-button]");
const actionsEl = this.el.querySelector("[data-actions]");
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,
danger,
html,
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;
if (html) {
descriptionEl.innerHTML = description;
} else {
descriptionEl.textContent = description;
}
confirmTextEl.textContent = confirm_text;
if (confirm_icon) {
confirmIconEl.className = `align-middle mr-1 ri-${confirm_icon}`;
} else {
confirmIconEl.className = "hidden";
}
confirmButtonEl.classList.toggle("button-red", danger);
confirmButtonEl.classList.toggle("button-blue", !danger);
actionsEl.classList.toggle("flex-row-reverse", danger);
actionsEl.classList.toggle("space-x-reverse", danger);
optOutElCheckbox.checked = false;
optOutEl.classList.toggle("hidden", !opt_out_id);
liveSocket.execJS(this.el, this.el.getAttribute("data-js-show"));
}
};
// Events dispatched with JS.dispatch
window.addEventListener("lb:confirm_request", this.handleConfirmRequest);
// Events dispatched with push_event
window.addEventListener(
"phx: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);
}
// Events dispatched with push_event have window as target,
// in which case we pass body, which is an actual element
const target =
confirmEvent.target === window ? document.body : confirmEvent.target;
liveSocket.execJS(target, confirmEvent.detail.on_confirm);
});
},
destroyed() {
window.removeEventListener("lb:confirm_request", this.handleConfirmRequest);
window.removeEventListener(
"phx:lb:confirm_request",
this.handleConfirmRequest
);
},
};
export default ConfirmModal;

View file

@ -2,7 +2,6 @@ import AppAuth from "./app_auth";
import AudioInput from "./audio_input";
import Cell from "./cell";
import CellEditor from "./cell_editor";
import ConfirmModal from "./confirm_modal";
import Dropzone from "./dropzone";
import EditorSettings from "./editor_settings";
import EmojiPicker from "./emoji_picker";
@ -26,7 +25,6 @@ export default {
AudioInput,
Cell,
CellEditor,
ConfirmModal,
Dropzone,
EditorSettings,
EmojiPicker,

View file

@ -62,6 +62,7 @@ defmodule LivebookWeb do
# Core UI components
import LivebookWeb.CoreComponents
import LivebookWeb.FormComponents
import LivebookWeb.Confirm
# Shortcut for generating JS commands
alias Phoenix.LiveView.JS

View file

@ -0,0 +1,155 @@
defmodule LivebookWeb.Confirm do
use Phoenix.Component
import LivebookWeb.CoreComponents
import Phoenix.LiveView
alias Phoenix.LiveView.JS
@doc """
Shows a confirmation modal.
On confirmation runs `on_confirm`. The function receives a socket
and should return the socket. Note that this socket always comes from
the root LV level, keep this in mind when using in a component and
dealing with assigns.
Make sure to render `confirm_root/1` in the layout.
## 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
* `:danger` - whether the action is destructive or regular. Defaults to `true`
* `: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
"""
def confirm(socket, on_confirm, opts) do
opts =
Keyword.validate!(
opts,
title: "Are you sure?",
description: nil,
confirm_text: "Yes",
confirm_icon: nil,
danger: true,
opt_out_id: nil
)
send(self(), {:confirm, on_confirm, opts})
socket
end
@doc """
Renders the confirmation modal for `confirm/3`.
"""
attr :confirm_state, :map, required: true
def confirm_root(assigns) do
~H"""
<.confirm_modal :if={@confirm_state} id={"confirm-#{@confirm_state.id}"} {@confirm_state.attrs} />
"""
end
defp confirm_modal(assigns) do
~H"""
<.modal id={@id} width={:medium} show={true}>
<form
id={"#{@id}-confirm-content"}
class="p-6 flex flex-col"
phx-submit={JS.push("confirm") |> hide_modal(@id)}
data-el-confirm-form
>
<h3 class="text-2xl font-semibold text-gray-800">
<%= @title %>
</h3>
<p class="mt-8 text-gray-700">
<%= @description %>
</p>
<label :if={@opt_out_id} class="mt-6 text-gray-700 flex items-center">
<input class="checkbox mr-3" type="checkbox" name="opt_out_id" value={@opt_out_id} />
<span class="text-sm">
Don't show this message again
</span>
</label>
<div class="mt-8 flex justify-end">
<div class={["flex gap-2", @danger && "flex-row-reverse"]}>
<button class="button-base button-outlined-gray" type="button" phx-click={hide_modal(@id)}>
Cancel
</button>
<button
class={["button-base", if(@danger, do: "button-red", else: "button-blue")]}
type="submit"
>
<.remix_icon :if={@confirm_icon} icon={@confirm_icon} class="align-middle mr-1" />
<span><%= @confirm_text %></span>
</button>
</div>
</div>
</form>
</.modal>
"""
end
def on_mount(:default, _params, _session, socket) do
connect_params = get_connect_params(socket) || %{}
# Opting out is a per-user preference, so we store it on the client
# and send in the connect params
confirm_opt_out_ids = connect_params["confirm_opt_out_ids"] || []
socket =
socket
|> assign(confirm_state: nil, confirm_opt_out_ids: MapSet.new(confirm_opt_out_ids))
|> attach_hook(:confirm, :handle_event, &handle_event/3)
|> attach_hook(:confirm, :handle_info, &handle_info/2)
{:cont, socket}
end
defp handle_event("confirm", params, socket) do
socket =
if opt_out_id = params["opt_out_id"] do
socket
|> update(:confirm_opt_out_ids, &MapSet.put(&1, opt_out_id))
|> push_event("add_confirm_opt_out_id", %{opt_out_id: opt_out_id})
else
socket
end
socket = socket.assigns.confirm_state.on_confirm.(socket)
{:halt, socket}
end
defp handle_event(_event, _params, socket), do: {:cont, socket}
defp handle_info({:confirm, on_confirm, opts}, socket) do
socket =
if opts[:opt_out_id] && opts[:opt_out_id] in socket.assigns.confirm_opt_out_ids do
on_confirm.(socket)
else
assign(socket,
confirm_state: %{
id: Livebook.Utils.random_short_id(),
on_confirm: on_confirm,
attrs: opts
}
)
end
{:cont, socket}
end
defp handle_info(_message, socket), do: {:cont, socket}
end

View file

@ -201,124 +201,6 @@ defmodule LivebookWeb.CoreComponents do
|> JS.dispatch("click", to: "##{id}-return")
end
@doc """
Renders the confirmation modal for `with_confirm/3`.
"""
attr :id, :string, required: true
def confirm_modal(assigns) do
~H"""
<.modal id={@id} width={:medium} phx-hook="ConfirmModal" data-js-show={show_modal(@id)}>
<div id={"#{@id}-confirm-content"} class="p-6 flex flex-col" phx-update="ignore">
<h3 class="text-2xl font-semibold text-gray-800" data-title></h3>
<p class="mt-8 text-gray-700" data-description></p>
<label class="mt-6 text-gray-700 flex items-center" data-opt-out>
<input class="checkbox mr-3" type="checkbox" />
<span class="text-sm">
Don't show this message again
</span>
</label>
<div class="mt-8 flex justify-end">
<div class="flex space-x-2" data-actions>
<button class="button-base button-outlined-gray" phx-click={hide_modal(@id)}>
Cancel
</button>
<button
class="button-base"
phx-click={hide_modal(@id) |> JS.dispatch("lb:confirm", to: "##{@id}")}
data-confirm-button
>
<i aria-hidden="true" data-confirm-icon></i>
<span data-confirm-text></span>
</button>
</div>
</div>
</div>
</.modal>
"""
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
* `:danger` - whether the action is destructive or regular. Defaults to `true`
* `:html` - whether the `:description` is a raw HTML. Defaults to `false`
* `: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
<button class="..."
phx-click={
with_confirm(
JS.push("delete_item", value: %{id: @item_id}),
title: "Delete item",
description: "Are you sure you want to delete item?",
confirm_text: "Delete",
confirm_icon: "delete-bin-6-line",
opt_out_id: "delete-item"
)
}>
Delete
</button>
"""
def with_confirm(js \\ %JS{}, on_confirm, opts) do
JS.dispatch(js, "lb:confirm_request", detail: confirm_payload(on_confirm, opts))
end
@doc """
Asks the client to show a confirmation modal before executing the
given JS action.
Same as `with_confirm/3`, except it is triggered from the server.
"""
def confirm(socket, on_confirm, opts) do
Phoenix.LiveView.push_event(socket, "lb:confirm_request", confirm_payload(on_confirm, opts))
end
defp confirm_payload(on_confirm, opts) do
opts =
Keyword.validate!(
opts,
[
:confirm_icon,
:description,
:opt_out_id,
title: "Are you sure?",
confirm_text: "Yes",
danger: true,
html: false
]
)
%{
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],
danger: opts[:danger],
html: opts[:html],
opt_out_id: opts[:opt_out_id]
}
end
@doc """
Renders a popup menu that shows up on toggle click.
@ -636,6 +518,46 @@ defmodule LivebookWeb.CoreComponents do
"""
end
@doc """
Returns the text in singular or plural depending on the quantity.
## Examples
<.listing items={@packages}>
<:item :let={package}><code><%= package.name %></code></:item>
<:singular_suffix>package</:singular_suffix>
<:plural_suffix>packages</:plural_suffix>
</.listing>
"""
attr :items, :list, required: true
slot :item, required: true
slot :plural_suffix
slot :singular_suffix
def listing(%{items: [_]} = assigns) do
~H"""
<%= render_slot(@item, hd(@items)) %>
<%= render_slot(@singular_suffix) %>
"""
end
def listing(%{items: [_, _ | _]} = assigns) do
{items, assigns} = Map.pop!(assigns, :items)
{leading, [second_to_last, last]} = Enum.split(items, -2)
assigns = assign(assigns, leading: leading, second_to_last: second_to_last, last: last)
~H"""
<%= for item <- @leading do %>
<%= render_slot(@item, item) %>,
<% end %>
<%= render_slot(@item, @second_to_last) %> and <%= render_slot(@item, @last) %>
<%= render_slot(@plural_suffix) %>
"""
end
# JS commands
@doc """

View file

@ -3,4 +3,4 @@
<%= @inner_content %>
</main>
<.confirm_modal id="global-confirm" />
<.confirm_root confirm_state={@confirm_state} />

View file

@ -74,34 +74,4 @@ defmodule LivebookWeb.Helpers do
@spec pluralize(non_neg_integer(), String.t(), String.t()) :: String.t()
def pluralize(1, singular, _plural), do: "1 #{singular}"
def pluralize(count, _singular, plural), do: "#{count} #{plural}"
@doc """
Returns the text in singular or plural depending on the quantity
## Examples
iex> LivebookWeb.Helpers.format_items(["tea"])
"tea"
iex> LivebookWeb.Helpers.format_items(["tea", "coffee"])
"tea and coffee"
iex> LivebookWeb.Helpers.format_items(["wine", "tea", "coffee"])
"wine, tea and coffee"
"""
@spec format_items(list(String.t())) :: String.t()
def format_items([]), do: ""
def format_items([item]), do: item
def format_items(list) do
{leading, [last]} = Enum.split(list, -1)
Enum.join(leading, ", ") <> " and " <> last
end
@doc """
Wraps the given text in a `<code>` tag.
"""
@spec code_tag(String.t()) :: String.t()
def code_tag(text), do: "<code>#{text}</code>"
end

View file

@ -59,15 +59,7 @@ defmodule LivebookWeb.EnvVarsComponent do
<button
id={"env-var-#{@env_var.name}-delete"}
type="button"
phx-click={
with_confirm(
JS.push("delete_env_var", value: %{env_var: @env_var.name}),
title: "Delete #{@env_var.name}",
description: "Are you sure you want to delete environment variable?",
confirm_text: "Delete",
confirm_icon: "delete-bin-6-line"
)
}
phx-click={JS.push("delete_env_var", value: %{env_var: @env_var.name})}
phx-target={@target}
role="menuitem"
>

View file

@ -120,15 +120,7 @@ defmodule LivebookWeb.HomeLive do
<span class="tooltip top" data-tooltip="Unstar">
<button
aria-label="unstar notebook"
phx-click={
with_confirm(
JS.push("unstar_notebook", value: %{idx: idx}),
title: "Unstar notebook",
description: "Once you unstar this notebook, you can always star it again.",
confirm_text: "Unstar",
opt_out_id: "unstar-notebook"
)
}
phx-click={JS.push("unstar_notebook", value: %{idx: idx})}
>
<.remix_icon icon="star-fill" class="text-yellow-600" />
</button>
@ -278,9 +270,19 @@ defmodule LivebookWeb.HomeLive do
end
def handle_event("unstar_notebook", %{"idx" => idx}, socket) do
%{file: file} = Enum.fetch!(socket.assigns.starred_notebooks, idx)
Livebook.NotebookManager.remove_starred_notebook(file)
{:noreply, socket}
on_confirm = fn socket ->
%{file: file} = Enum.fetch!(socket.assigns.starred_notebooks, idx)
Livebook.NotebookManager.remove_starred_notebook(file)
socket
end
{:noreply,
confirm(socket, on_confirm,
title: "Unstar notebook",
description: "Once you unstar this notebook, you can always star it again.",
confirm_text: "Unstar",
opt_out_id: "unstar-notebook"
)}
end
def handle_event("bulk_action", %{"action" => "disconnect"} = params, socket) do

View file

@ -3,6 +3,7 @@ defmodule LivebookWeb.SidebarHook do
import Phoenix.Component
import Phoenix.LiveView
import LivebookWeb.Confirm
def on_mount(:default, _params, _session, socket) do
if connected?(socket) do
@ -37,8 +38,18 @@ defmodule LivebookWeb.SidebarHook do
defp handle_info(_event, socket), do: {:cont, socket}
defp handle_event("shutdown", _params, socket) do
Livebook.Config.shutdown()
{:halt, socket}
on_confirm = fn socket ->
Livebook.Config.shutdown()
socket
end
{:halt,
confirm(socket, on_confirm,
title: "Shut Down",
description: "Are you sure you want to shut down Livebook now?",
confirm_text: "Shut Down",
confirm_icon: "shut-down-line"
)}
end
defp handle_event(_event, _params, socket), do: {:cont, socket}

View file

@ -22,15 +22,7 @@ defmodule LivebookWeb.Hub.Edit.EnterpriseComponent do
<LayoutHelpers.title text={"#{@hub.hub_emoji} #{@hub.hub_name}"} />
<button
phx-click={
with_confirm(
JS.push("delete_hub", value: %{id: @hub.id}),
title: "Delete hub",
description: "Are you sure you want to delete this hub?",
confirm_text: "Delete",
confirm_icon: "close-circle-line"
)
}
phx-click={JS.push("delete_hub", value: %{id: @hub.id})}
class="absolute right-0 button-base button-red"
>
Delete hub

View file

@ -40,15 +40,7 @@ defmodule LivebookWeb.Hub.Edit.FlyComponent do
<LayoutHelpers.title text={"#{@hub.hub_emoji} #{@hub.hub_name}"} />
<button
phx-click={
with_confirm(
JS.push("delete_hub", value: %{id: @hub.id}),
title: "Delete hub",
description: "Are you sure you want to delete this hub?",
confirm_text: "Delete",
confirm_icon: "close-circle-line"
)
}
phx-click={JS.push("delete_hub", value: %{id: @hub.id})}
class="absolute right-0 button-base button-red"
>
Delete hub
@ -195,18 +187,26 @@ defmodule LivebookWeb.Hub.Edit.FlyComponent do
end
def handle_event("delete_env_var", %{"env_var" => key}, socket) do
case FlyClient.unset_secrets(socket.assigns.hub, [key]) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:success, "Environment variable deleted")
|> push_navigate(to: ~p"/hub/#{socket.assigns.hub.id}")}
on_confirm = fn socket ->
case FlyClient.unset_secrets(socket.assigns.hub, [key]) do
{:ok, _} ->
socket
|> put_flash(:success, "Environment variable deleted")
|> push_navigate(to: ~p"/hub/#{socket.assigns.hub.id}")
{:error, _} ->
{:noreply,
socket
|> put_flash(:error, "Failed to delete environment variable")
|> push_navigate(to: ~p"/hub/#{socket.assigns.hub.id}")}
{:error, _} ->
socket
|> put_flash(:error, "Failed to delete environment variable")
|> push_navigate(to: ~p"/hub/#{socket.assigns.hub.id}")
end
end
{:noreply,
confirm(socket, on_confirm,
title: "Delete #{key}",
description: "Are you sure you want to delete environment variable?",
confirm_text: "Delete",
confirm_icon: "delete-bin-6-line"
)}
end
end

View file

@ -214,19 +214,13 @@ defmodule LivebookWeb.Hub.Edit.PersonalComponent do
id={"hub-secret-#{@secret.name}-delete"}
type="button"
phx-click={
with_confirm(
JS.push("delete_hub_secret",
value: %{
name: @secret.name,
value: @secret.value,
hub_id: @secret.hub_id
},
target: @target
),
title: "Delete hub secret - #{@secret.name}",
description: "Are you sure you want to delete this hub secret?",
confirm_text: "Delete",
confirm_icon: "delete-bin-6-line"
JS.push("delete_hub_secret",
value: %{
name: @secret.name,
value: @secret.value,
hub_id: @secret.hub_id
},
target: @target
)
}
phx-target={@target}
@ -265,10 +259,21 @@ defmodule LivebookWeb.Hub.Edit.PersonalComponent do
end
def handle_event("delete_hub_secret", attrs, socket) do
{:ok, secret} = Livebook.Secrets.update_secret(%Livebook.Secrets.Secret{}, attrs)
:ok = Livebook.Hubs.delete_secret(socket.assigns.hub, secret)
%{hub: hub} = socket.assigns
{:noreply, socket}
on_confirm = fn socket ->
{:ok, secret} = Livebook.Secrets.update_secret(%Livebook.Secrets.Secret{}, attrs)
:ok = Livebook.Hubs.delete_secret(hub, secret)
socket
end
{:noreply,
confirm(socket, on_confirm,
title: "Delete hub secret - #{attrs["name"]}",
description: "Are you sure you want to delete this hub secret?",
confirm_text: "Delete",
confirm_icon: "delete-bin-6-line"
)}
end
defp save(params, changeset_name, socket) do

View file

@ -78,12 +78,21 @@ defmodule LivebookWeb.Hub.EditLive do
@impl true
def handle_event("delete_hub", %{"id" => id}, socket) do
Hubs.delete_hub(id)
on_confirm = fn socket ->
Hubs.delete_hub(id)
socket
|> put_flash(:success, "Hub deleted successfully")
|> push_navigate(to: "/")
end
{:noreply,
socket
|> put_flash(:success, "Hub deleted successfully")
|> push_navigate(to: "/")}
confirm(socket, on_confirm,
title: "Delete hub",
description: "Are you sure you want to delete this hub?",
confirm_text: "Delete",
confirm_icon: "close-circle-line"
)}
end
@impl true

View file

@ -114,15 +114,7 @@ defmodule LivebookWeb.LayoutHelpers do
:if={Livebook.Config.shutdown_callback()}
class="h-7 flex items-center text-gray-400 hover:text-white border-l-4 border-transparent hover:border-white"
aria-label="shutdown"
phx-click={
with_confirm(
JS.push("shutdown"),
title: "Shut Down",
description: "Are you sure you want to shut down Livebook now?",
confirm_text: "Shut Down",
confirm_icon: "shut-down-line"
)
}
phx-click="shutdown"
>
<.remix_icon icon="shut-down-line" class="text-lg leading-6 w-[56px] flex justify-center" />
<span class="text-sm font-medium">

View file

@ -118,15 +118,7 @@ defmodule LivebookWeb.OpenLive do
<span class="tooltip top" data-tooltip="Hide notebook">
<button
aria-label="hide notebook"
phx-click={
with_confirm(
JS.push("hide_recent_notebook", value: %{idx: idx}),
title: "Hide notebook",
description: "The notebook will reappear here when you open it again.",
confirm_text: "Hide",
opt_out_id: "hide-notebook"
)
}
phx-click={JS.push("hide_recent_notebook", value: %{idx: idx})}
>
<.remix_icon icon="close-fill" class="text-gray-600 text-lg" />
</button>
@ -194,9 +186,19 @@ defmodule LivebookWeb.OpenLive do
end
def handle_event("hide_recent_notebook", %{"idx" => idx}, socket) do
%{file: file} = Enum.fetch!(socket.assigns.recent_notebooks, idx)
Livebook.NotebookManager.remove_recent_notebook(file)
{:noreply, socket}
on_confirm = fn socket ->
%{file: file} = Enum.fetch!(socket.assigns.recent_notebooks, idx)
Livebook.NotebookManager.remove_recent_notebook(file)
socket
end
{:noreply,
confirm(socket, on_confirm,
title: "Hide notebook",
description: "The notebook will reappear here when you open it again.",
confirm_text: "Hide",
opt_out_id: "hide-notebook"
)}
end
@impl true

View file

@ -920,85 +920,101 @@ defmodule LivebookWeb.SessionLive do
def handle_event("insert_code_block_below", params, socket) do
data = socket.private.data
add_dependencies? = params["add_dependencies"] == true
%{"section_id" => section_id, "cell_id" => cell_id} = params
with {:ok, section, index} <-
section_with_next_index(data.notebook, params["section_id"], params["cell_id"]),
{:ok, definition} <- code_block_definition_by_name(data, params["definition_name"]) do
variant = Enum.fetch!(definition.variants, params["variant_idx"])
dependencies = Enum.map(variant.packages, & &1.dependency)
case code_block_definition_by_name(data, params["definition_name"]) do
{:ok, definition} ->
variant = Enum.fetch!(definition.variants, params["variant_idx"])
dependencies = Enum.map(variant.packages, & &1.dependency)
has_dependencies? =
dependencies == [] or Livebook.Runtime.has_dependencies?(data.runtime, dependencies)
has_dependencies? =
dependencies == [] or Livebook.Runtime.has_dependencies?(data.runtime, dependencies)
cond do
has_dependencies? or add_dependencies? ->
attrs = %{source: variant.source}
Session.insert_cell(socket.assigns.session.pid, section.id, index, :code, attrs)
cond do
has_dependencies? ->
insert_code_block_below(socket, variant, section_id, cell_id)
{:noreply, socket}
socket =
if has_dependencies? do
socket
else
add_dependencies_and_reevaluate(socket, dependencies)
Livebook.Runtime.fixed_dependencies?(data.runtime) ->
{:noreply,
put_flash(socket, :error, "This runtime doesn't support adding dependencies")}
true ->
on_confirm = fn socket ->
case insert_code_block_below(socket, variant, section_id, cell_id) do
:ok -> add_dependencies_and_reevaluate(socket, dependencies)
:error -> socket
end
end
{:noreply, socket}
socket =
confirm_add_packages(socket, on_confirm, variant.packages, definition.name, "block")
Livebook.Runtime.fixed_dependencies?(data.runtime) ->
{:noreply,
put_flash(socket, :error, "This runtime doesn't support adding dependencies")}
{:noreply, socket}
end
true ->
js = JS.push("insert_code_block_below", value: put_in(params["add_dependencies"], true))
socket = confirm_add_packages(socket, js, variant.packages, definition.name, "block")
{:noreply, socket}
end
else
_ -> {:noreply, socket}
_ ->
{:noreply, socket}
end
end
def handle_event("insert_smart_cell_below", params, socket) do
data = socket.private.data
add_dependencies? = params["add_dependencies"] == true
%{"section_id" => section_id, "cell_id" => cell_id} = params
with {:ok, section, index} <-
section_with_next_index(data.notebook, params["section_id"], params["cell_id"]),
{:ok, definition} <- smart_cell_definition_by_kind(data, params["kind"]) do
preset =
if preset_idx = params["preset_idx"] do
Enum.at(definition.requirement_presets, preset_idx)
end
if preset == nil or add_dependencies? do
attrs = %{kind: params["kind"]}
Session.insert_cell(socket.assigns.session.pid, section.id, index, :smart, attrs)
socket =
if preset == nil do
socket
else
{:ok, preset} = Enum.fetch(definition.requirement_presets, preset_idx)
dependencies = Enum.map(preset.packages, & &1.dependency)
add_dependencies_and_reevaluate(socket, dependencies)
case smart_cell_definition_by_kind(data, params["kind"]) do
{:ok, definition} ->
preset =
if preset_idx = params["preset_idx"] do
Enum.at(definition.requirement_presets, preset_idx)
end
if preset == nil do
insert_smart_cell_below(socket, definition, section_id, cell_id)
{:noreply, socket}
else
on_confirm = fn socket ->
case insert_smart_cell_below(socket, definition, section_id, cell_id) do
:ok ->
dependencies = Enum.map(preset.packages, & &1.dependency)
add_dependencies_and_reevaluate(socket, dependencies)
:error ->
socket
end
end
socket =
confirm_add_packages(
socket,
on_confirm,
preset.packages,
definition.name,
"smart cell"
)
{:noreply, socket}
end
_ ->
{:noreply, socket}
else
js = JS.push("insert_smart_cell_below", value: put_in(params["add_dependencies"], true))
socket = confirm_add_packages(socket, js, preset.packages, definition.name, "smart cell")
{:noreply, socket}
end
else
_ -> {:noreply, socket}
end
end
def handle_event("delete_cell", %{"cell_id" => cell_id}, socket) do
Session.delete_cell(socket.assigns.session.pid, cell_id)
on_confirm = fn socket ->
Session.delete_cell(socket.assigns.session.pid, cell_id)
socket
end
{:noreply, socket}
{:noreply,
confirm(socket, on_confirm,
title: "Delete cell",
description: "Once you delete this cell, it will be moved to the bin.",
confirm_text: "Delete",
confirm_icon: "delete-bin-6-line",
opt_out_id: "delete-cell"
)}
end
def handle_event("set_notebook_name", %{"value" => name}, socket) do
@ -1078,9 +1094,20 @@ defmodule LivebookWeb.SessionLive do
end
def handle_event("convert_smart_cell", %{"cell_id" => cell_id}, socket) do
Session.convert_smart_cell(socket.assigns.session.pid, cell_id)
on_confirm = fn socket ->
Session.convert_smart_cell(socket.assigns.session.pid, cell_id)
socket
end
{:noreply, socket}
{:noreply,
confirm(socket, on_confirm,
title: "Convert cell",
description:
"Once you convert this Smart cell to a Code cell, the Smart cell will be moved to the bin.",
confirm_text: "Convert",
confirm_icon: "arrow-up-down-line",
opt_out_id: "convert-smart-cell"
)}
end
def handle_event("add_form_cell_dependencies", %{}, socket) do
@ -1167,14 +1194,25 @@ defmodule LivebookWeb.SessionLive do
{:noreply, socket}
end
def handle_event("setup_default_runtime", %{}, socket) do
{status, socket} = connect_runtime(socket)
def handle_event("setup_default_runtime", %{"reason" => reason}, socket) do
on_confirm = fn socket ->
{status, socket} = connect_runtime(socket)
if status == :ok do
Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.setup_cell_id())
if status == :ok do
Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.setup_cell_id())
end
socket
end
{:noreply, socket}
{:noreply,
confirm(socket, on_confirm,
title: "Setup runtime",
description: "#{reason} Do you want to connect and setup the default one?",
confirm_text: "Setup runtime",
confirm_icon: "play-line",
danger: false
)}
end
def handle_event("disconnect_runtime", %{}, socket) do
@ -1855,32 +1893,45 @@ defmodule LivebookWeb.SessionLive do
socket
end
defp confirm_add_packages(socket, js, packages, target_name, target_type) do
confirm(socket, js,
title: "Add packages",
description:
case packages do
[package] ->
~s'''
The <span class="font-semibold">#{target_name}“</span> #{target_type} requires
the #{code_tag(package.name)} package. Do you want to add it as a dependency
and restart?
'''
defp confirm_add_packages(socket, on_confirm, packages, target_name, target_type) do
assigns = %{packages: packages, target_name: target_name, target_type: target_type}
packages ->
~s'''
The <span class="font-semibold">#{target_name}“</span> #{target_type} requires the
#{packages |> Enum.map(&code_tag(&1.name)) |> format_items()} packages. Do you want
to add them as dependencies and restart?
'''
end,
description = ~H"""
The <span class="font-semibold"><%= @target_name %></span> <%= @target_type %> requires the
<.listing items={@packages}>
<:item :let={package}><code><%= package.name %></code></:item>
<:singular_suffix>package. Do you want to add it as a dependency and restart?</:singular_suffix>
<:plural_suffix>packages. Do you want to add them as dependencies and restart?</:plural_suffix>
</.listing>
"""
confirm(socket, on_confirm,
title: "Add packages",
description: description,
confirm_text: "Add and restart",
confirm_icon: "add-line",
danger: false,
html: true
danger: false
)
end
defp insert_code_block_below(socket, variant, section_id, cell_id) do
with {:ok, section, index} <-
section_with_next_index(socket.private.data.notebook, section_id, cell_id) do
attrs = %{source: variant.source}
Session.insert_cell(socket.assigns.session.pid, section.id, index, :code, attrs)
:ok
end
end
defp insert_smart_cell_below(socket, definition, section_id, cell_id) do
with {:ok, section, index} <-
section_with_next_index(socket.private.data.notebook, section_id, cell_id) do
attrs = %{kind: definition.kind}
Session.insert_cell(socket.assigns.session.pid, section.id, index, :smart, attrs)
:ok
end
end
# Builds view-specific structure of data by cherry-picking
# only the relevant attributes.
# We then use `@data_view` in the templates and consequently

View file

@ -446,17 +446,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
class="icon-button"
aria-label="toggle source"
data-link-package-search
phx-click={
with_confirm(
JS.push("convert_smart_cell", value: %{cell_id: @cell_id}),
title: "Convert cell",
description:
"Once you convert this Smart cell to a Code cell, the Smart cell will be moved to the bin.",
confirm_text: "Convert",
confirm_icon: "arrow-up-down-line",
opt_out_id: "convert-smart-cell"
)
}
phx-click={JS.push("convert_smart_cell", value: %{cell_id: @cell_id})}
>
<.remix_icon icon="pencil-line" class="text-xl" />
</button>
@ -563,16 +553,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
<button
class="icon-button"
aria-label="delete cell"
phx-click={
with_confirm(
JS.push("delete_cell", value: %{cell_id: @cell_id}),
title: "Delete cell",
description: "Once you delete this cell, it will be moved to the bin.",
confirm_text: "Delete",
confirm_icon: "delete-bin-6-line",
opt_out_id: "delete-cell"
)
}
phx-click={JS.push("delete_cell", value: %{cell_id: @cell_id})}
>
<.remix_icon icon="delete-bin-6-line" class="text-xl" />
</button>

View file

@ -92,8 +92,8 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
<button
class="button-base button-small"
phx-click={
setup_runtime_with_confirm(
"To see the available smart cells, you need a connected runtime."
JS.push("setup_default_runtime",
value: %{reason: "To see the available smart cells, you need a connected runtime."}
)
}
>
@ -194,21 +194,12 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
}
)
else
setup_runtime_with_confirm("To insert this block, you need a connected runtime.")
JS.push("setup_default_runtime",
value: %{reason: "To insert this block, you need a connected runtime."}
)
end
end
defp setup_runtime_with_confirm(reason) do
with_confirm(
JS.push("setup_default_runtime"),
title: "Setup runtime",
description: "#{reason} Do you want to connect and setup the default one?",
confirm_text: "Setup runtime",
confirm_icon: "play-line",
danger: false
)
end
defp on_smart_cell_click(definition, section_id, cell_id) do
preset_idx = if definition.requirement_presets == [], do: nil, else: 0
on_smart_cell_click(definition, preset_idx, section_id, cell_id)

View file

@ -46,16 +46,7 @@ defmodule LivebookWeb.SessionLive.SecretsListComponent do
id={"session-secret-#{secret.name}-delete"}
type="button"
phx-click={
with_confirm(
JS.push("delete_session_secret",
value: %{secret_name: secret.name},
target: @myself
),
title: "Delete session secret - #{secret.name}",
description: "Are you sure you want to delete this session secret?",
confirm_text: "Delete",
confirm_icon: "delete-bin-6-line"
)
JS.push("delete_session_secret", value: %{secret_name: secret.name}, target: @myself)
}
class="hover:text-gray-900"
>
@ -158,19 +149,13 @@ defmodule LivebookWeb.SessionLive.SecretsListComponent do
id={"#{@id}-delete"}
type="button"
phx-click={
with_confirm(
JS.push("delete_hub_secret",
value: %{
name: @secret.name,
value: @secret.value,
hub_id: @secret.hub_id
},
target: @myself
),
title: "Delete hub secret - #{@secret.name}",
description: "Are you sure you want to delete this hub secret?",
confirm_text: "Delete",
confirm_icon: "delete-bin-6-line"
JS.push("delete_hub_secret",
value: %{
name: @secret.name,
value: @secret.value,
hub_id: @secret.hub_id
},
target: @myself
)
}
class="hover:text-gray-900"
@ -222,15 +207,36 @@ defmodule LivebookWeb.SessionLive.SecretsListComponent do
end
def handle_event("delete_session_secret", %{"secret_name" => secret_name}, socket) do
Session.unset_secret(socket.assigns.session.pid, secret_name)
{:noreply, socket}
on_confirm = fn socket ->
Session.unset_secret(socket.assigns.session.pid, secret_name)
socket
end
{:noreply,
confirm(socket, on_confirm,
title: "Delete session secret - #{secret_name}",
description: "Are you sure you want to delete this session secret?",
confirm_text: "Delete",
confirm_icon: "delete-bin-6-line"
)}
end
def handle_event("delete_hub_secret", attrs, socket) do
{:ok, secret} = Secrets.update_secret(%Secret{}, attrs)
:ok = Hubs.delete_secret(socket.assigns.hub, secret)
:ok = Session.unset_secret(socket.assigns.session.pid, secret.name)
%{hub: hub, session: session} = socket.assigns
{:noreply, socket}
on_confirm = fn socket ->
{:ok, secret} = Secrets.update_secret(%Secret{}, attrs)
:ok = Hubs.delete_secret(hub, secret)
:ok = Session.unset_secret(session.pid, secret.name)
socket
end
{:noreply,
confirm(socket, on_confirm,
title: "Delete hub secret - #{attrs["name"]}",
description: "Are you sure you want to delete this hub secret?",
confirm_text: "Delete",
confirm_icon: "delete-bin-6-line"
)}
end
end

View file

@ -305,14 +305,24 @@ defmodule LivebookWeb.SettingsLive do
end
def handle_event("detach_file_system", %{"id" => file_system_id}, socket) do
Livebook.Settings.remove_file_system(file_system_id)
on_confirm = fn socket ->
Livebook.Settings.remove_file_system(file_system_id)
file_systems = Livebook.Settings.file_systems()
file_systems = Livebook.Settings.file_systems()
assign(socket,
file_systems: file_systems,
default_file_system_id: Livebook.Settings.default_file_system_id()
)
end
{:noreply,
assign(socket,
file_systems: file_systems,
default_file_system_id: Livebook.Settings.default_file_system_id()
confirm(socket, on_confirm,
title: "Detach file system",
description:
"Are you sure you want to detach this file system? Any sessions using it will keep the access until they get closed.",
confirm_text: "Detach",
confirm_icon: "close-circle-line"
)}
end
@ -344,8 +354,18 @@ defmodule LivebookWeb.SettingsLive do
end
def handle_event("delete_env_var", %{"env_var" => key}, socket) do
Livebook.Settings.unset_env_var(key)
{:noreply, socket}
on_confirm = fn socket ->
Livebook.Settings.unset_env_var(key)
socket
end
{:noreply,
confirm(socket, on_confirm,
title: "Delete #{key}",
description: "Are you sure you want to delete environment variable?",
confirm_text: "Delete",
confirm_icon: "delete-bin-6-line"
)}
end
@impl true

View file

@ -79,16 +79,7 @@ defmodule LivebookWeb.SettingsLive.FileSystemsComponent do
type="button"
role="menuitem"
class="text-red-600"
phx-click={
with_confirm(
JS.push("detach_file_system", value: %{id: @file_system_id}),
title: "Detach file system",
description:
"Are you sure you want to detach this file system? Any sessions using it will keep the access until they get closed.",
confirm_text: "Detach",
confirm_icon: "close-circle-line"
)
}
phx-click={JS.push("detach_file_system", value: %{id: @file_system_id})}
>
<.remix_icon icon="delete-bin-line" />
<span>Detach</span>

View file

@ -54,7 +54,8 @@ defmodule LivebookWeb.Router do
get "/sessions/:id/assets/:hash/*file_parts", SessionController, :show_asset
end
live_session :default, on_mount: [LivebookWeb.AuthHook, LivebookWeb.UserHook] do
live_session :default,
on_mount: [LivebookWeb.AuthHook, LivebookWeb.UserHook, LivebookWeb.Confirm] do
scope "/", LivebookWeb do
pipe_through [:browser, :auth]
@ -106,7 +107,8 @@ defmodule LivebookWeb.Router do
end
end
live_session :apps, on_mount: [LivebookWeb.AppAuthHook, LivebookWeb.UserHook] do
live_session :apps,
on_mount: [LivebookWeb.AppAuthHook, LivebookWeb.UserHook, LivebookWeb.Confirm] do
scope "/", LivebookWeb do
pipe_through [:browser, :user]

View file

@ -2,6 +2,7 @@ defmodule LivebookWeb.Hub.EditLiveTest do
use LivebookWeb.ConnCase
import Phoenix.LiveViewTest
import Livebook.TestHelpers
alias Livebook.Hubs
@ -65,9 +66,13 @@ defmodule LivebookWeb.Hub.EditLiveTest do
{:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
view
|> element("button", "Delete hub")
|> render_click()
assert {:ok, view, _html} =
view
|> render_click("delete_hub", %{"id" => hub_id})
|> render_confirm()
|> follow_redirect(conn)
hubs_html = view |> element("#hubs") |> render()
@ -191,10 +196,13 @@ defmodule LivebookWeb.Hub.EditLiveTest do
:ok = Agent.update(pid, fn state -> %{state | type: :mount} end)
view
|> element(~s/#env-var-FOO_ENV_VAR-delete/)
|> render_click()
assert {:ok, _view, html} =
view
|> with_target("#fly-form-component")
|> render_click("delete_env_var", %{"env_var" => "FOO_ENV_VAR"})
|> render_confirm()
|> follow_redirect(conn)
assert html =~ "Environment variable deleted"
@ -355,16 +363,14 @@ defmodule LivebookWeb.Hub.EditLiveTest do
|> has_element?()
view
|> with_target("#personal-form-component")
|> render_click("delete_hub_secret", %{
name: secret.name,
value: secret.value,
hub_id: secret.hub_id
})
|> element("#hub-secret-PERSONAL_DELETE_SECRET-delete", "Delete")
|> render_click()
render_confirm(view)
assert_receive {:secret_deleted, ^secret}
assert render(view) =~ "Secret deleted successfully"
refute render(view) =~ secret.name
refute render(element(view, "#hub-secrets-list")) =~ secret.name
refute secret in Livebook.Hubs.get_secrets(hub)
end
end

View file

@ -2,6 +2,7 @@ defmodule LivebookWeb.SessionLiveTest do
use LivebookWeb.ConnCase, async: true
import Livebook.SessionHelpers
import Livebook.TestHelpers
import Phoenix.LiveViewTest
alias Livebook.{Sessions, Session, Settings, Runtime, Users, FileSystem}
@ -246,6 +247,8 @@ defmodule LivebookWeb.SessionLiveTest do
|> element(~s{[data-el-session]})
|> render_hook("delete_cell", %{"cell_id" => cell_id})
render_confirm(view)
assert %{notebook: %{sections: [%{cells: []}]}} = Session.get_data(session.pid)
end

View file

@ -3,6 +3,7 @@ defmodule LivebookWeb.SettingsLiveTest do
@moduletag :tmp_dir
import Phoenix.LiveViewTest
import Livebook.TestHelpers
alias Livebook.Settings
@ -75,9 +76,15 @@ defmodule LivebookWeb.SettingsLiveTest do
assert html =~ env_var.name
render_click(view, "delete_env_var", %{"env_var" => env_var.name})
view
|> element("#env-var-#{env_var.name}-delete")
|> render_click()
refute render(view) =~ env_var.name
render_confirm(view)
refute view
|> element("#env-vars")
|> render() =~ env_var.name
end
end
end

View file

@ -1,6 +1,8 @@
defmodule Livebook.TestHelpers do
@moduledoc false
import Phoenix.LiveViewTest
alias Livebook.Session.Data
@doc """
@ -44,4 +46,14 @@ defmodule Livebook.TestHelpers do
Converts a Unix-like absolute path into OS-compatible absolute path.
"""
defmacro p("/" <> path), do: Path.expand("/") <> path
@doc """
Confirms the action guarded by `LivebookWeb.Confirm/3` and
returns the rendered result.
"""
def render_confirm(view) do
view
|> element(~s/[data-el-confirm-form]/)
|> render_submit()
end
end