Bulk actions for sessions (#939)

* Initial implementation to close multiple sessions

* Sessions: bulk actions with components

* Rename Disconnect sessions to Disconnect runtime

* Select all and disabled when nothing is selected

* Styled checkbox

* Renames toggle events

* Warning about not persisted notebooks

* Adds disconnect runtime option for a single session

* Edit sessions on right

* Fix: typos and plural

* Minor adjustments

* Removes the loop for rendering the menu

* Menus with fixed width

* Minor adjustments

* Pluralize as global helper

* Bulk actions form on client side

* Track bulk actions buttons state

* Fix: home live tests

* Doctests for pluralize

* Fix: bulk actions buttons losing state on session update

* Fix: format

* Minor adjustment on toggle_edit

* Review-based adjustments

* Reset the Edit state after single-session actions

* Minor adjustments

* Fixes bulk action events

* Submit the bulk action form directly

* Tests for bulk actions

* Indentation

* Update lib/livebook_web/live/home_live/close_session_component.ex

Co-authored-by: José Valim <jose.valim@gmail.com>

Co-authored-by: José Valim <jose.valim@gmail.com>
This commit is contained in:
Cristine Guadelupe 2022-01-28 17:45:04 -03:00 committed by GitHub
parent d07945738d
commit 4dd28388a5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 329 additions and 34 deletions

View file

@ -163,6 +163,19 @@
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='10' cy='10' r='9.5' stroke='%233E64FF' fill='white' /%3e%3ccircle cx='10' cy='10' r='6' fill='%233E64FF' /%3e%3c/svg%3e");
}
.checkbox-base {
@apply h-5 w-5 appearance-none border border-gray-300 rounded text-blue-600 cursor-pointer;
}
.checkbox-base:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
border-color: transparent;
background-color: currentColor;
background-size: 100% 100%;
background-position: center;
background-repeat: no-repeat;
}
/* Custom scrollbars */
.tiny-scrollbar::-webkit-scrollbar {
@ -212,6 +225,14 @@
@apply w-full flex space-x-3 px-5 py-2 items-center hover:bg-gray-100 focus:bg-gray-100 whitespace-nowrap;
}
.menu-item:disabled {
@apply pointer-events-none opacity-50;
}
.menu-item--disabled {
@apply pointer-events-none opacity-50;
}
/* Boxes */
.error-box {

View file

@ -91,6 +91,14 @@ window.addEventListener("lb:set_value", (event) => {
event.target.value = event.detail.value;
});
window.addEventListener("lb:check", (event) => {
event.target.checked = true;
});
window.addEventListener("lb:uncheck", (event) => {
event.target.checked = false;
});
window.addEventListener("lb:clipcopy", (event) => {
if ("clipboard" in navigator) {
const text = event.target.textContent;
@ -113,6 +121,18 @@ window.addEventListener("contextmenu", (event) => {
}
});
window.addEventListener("lb:session_list:on_selection_change", () => {
const anySessionSelected = !!document.querySelector(
"[name='session_ids[]']:checked"
);
const disconnect = document.querySelector(
"#edit-sessions [name='disconnect']"
);
const closeAll = document.querySelector("#edit-sessions [name='close_all']");
disconnect.disabled = !anySessionSelected;
closeAll.disabled = !anySessionSelected;
});
// Global configuration
settingsStore.getAndSubscribe((settings) => {

View file

@ -375,4 +375,18 @@ defmodule LivebookWeb.Helpers do
def file_system_label(%FileSystem.Local{}), do: "Local disk"
def file_system_label(%FileSystem.S3{} = fs), do: fs.bucket_url
@doc """
Returns the text in singular or plural depending on the quantity
## Examples
iex> LivebookWeb.Helpers.pluralize(1, "notebook is not persisted", "notebooks are not persisted")
"1 notebook is not persisted"
iex> LivebookWeb.Helpers.pluralize(3, "notebook is not persisted", "notebooks are not persisted")
"3 notebooks are not persisted"
"""
def pluralize(1, singular, _plural), do: "1 #{singular}"
def pluralize(count, _singular, plural), do: "#{count} #{plural}"
end

View file

@ -107,7 +107,7 @@ defmodule LivebookWeb.HomeLive do
<div class="py-12">
<.live_component module={LivebookWeb.HomeLive.SessionListComponent}
id="session-list"
sessions={@sessions} />
sessions={@sessions}/>
</div>
</div>
</div>
@ -136,6 +136,17 @@ defmodule LivebookWeb.HomeLive do
import_opts={@import_opts} />
</.modal>
<% end %>
<%= if @live_action == :edit_sessions do %>
<.modal class="w-full max-w-xl" return_to={Routes.home_path(@socket, :page)}>
<.live_component module={LivebookWeb.HomeLive.EditSessionsComponent}
id="edit-sessions"
action={@bulk_action}
return_to={Routes.home_path(@socket, :page)}
sessions={@sessions}
selected_sessions={selected_sessions(@sessions, @selected_session_ids)} />
</.modal>
<% end %>
"""
end
@ -153,6 +164,14 @@ defmodule LivebookWeb.HomeLive do
{:noreply, assign(socket, session: session)}
end
def handle_params(
%{"action" => action},
_url,
%{assigns: %{live_action: :edit_sessions}} = socket
) do
{:noreply, assign(socket, bulk_action: action)}
end
def handle_params(%{"tab" => tab} = params, _url, %{assigns: %{live_action: :import}} = socket) do
import_opts = [url: params["url"]]
{:noreply, assign(socket, tab: tab, import_opts: import_opts)}
@ -221,6 +240,22 @@ defmodule LivebookWeb.HomeLive do
{:noreply, socket}
end
def handle_event("bulk_action", %{"action" => "disconnect"} = params, socket) do
socket = assign(socket, selected_session_ids: params["session_ids"])
{:noreply, push_patch(socket, to: Routes.home_path(socket, :edit_sessions, "disconnect"))}
end
def handle_event("bulk_action", %{"action" => "close_all"} = params, socket) do
socket = assign(socket, selected_session_ids: params["session_ids"])
{:noreply, push_patch(socket, to: Routes.home_path(socket, :edit_sessions, "close_all"))}
end
def handle_event("disconnect_runtime", %{"id" => session_id}, socket) do
session = Enum.find(socket.assigns.sessions, &(&1.id == session_id))
Session.disconnect_runtime(session.pid)
{:noreply, socket}
end
def handle_event("fork_session", %{"id" => session_id}, socket) do
session = Enum.find(socket.assigns.sessions, &(&1.id == session_id))
%{images_dir: images_dir} = session
@ -345,4 +380,8 @@ defmodule LivebookWeb.HomeLive do
{:error, _} -> :none
end
end
defp selected_sessions(sessions, selected_session_ids) do
Enum.filter(sessions, &(&1.id in selected_session_ids))
end
end

View file

@ -1,6 +1,8 @@
defmodule LivebookWeb.HomeLive.CloseSessionComponent do
use LivebookWeb, :live_component
import LivebookWeb.HomeLive.SessionListComponent, only: [toggle_edit: 1]
@impl true
def render(assigns) do
~H"""
@ -14,10 +16,11 @@ defmodule LivebookWeb.HomeLive.CloseSessionComponent do
<br/>
<%= if @session.file,
do: "This won't delete any persisted files.",
else: "The notebook is not persisted and all content will be lost." %>
else: "The notebook is not persisted and content may be lost." %>
</p>
<div class="mt-8 flex justify-end space-x-2">
<button class="button-base button-red" phx-click="close" phx-target={@myself}>
<button class="button-base button-red" role="button"
phx-click={toggle_edit(:off) |> JS.push("close", target: @myself)}>
<.remix_icon icon="close-circle-line" class="align-middle mr-1" />
Close session
</button>

View file

@ -0,0 +1,76 @@
defmodule LivebookWeb.HomeLive.EditSessionsComponent do
use LivebookWeb, :live_component
import LivebookWeb.HomeLive.SessionListComponent, only: [toggle_edit: 1]
@impl true
def render(assigns) do
~H"""
<div class="p-6 pb-4 flex flex-col space-y-8">
<h3 class="text-2xl font-semibold text-gray-800">
<%= title(@action) %>
</h3>
<.message action={@action} selected_sessions={@selected_sessions} sessions={@sessions}/>
<div class="mt-8 flex justify-end space-x-2">
<button class="button-base button-red" role="button"
phx-click={toggle_edit(:off) |> JS.push(@action, target: @myself)}>
<.remix_icon icon="close-circle-line" class="align-middle mr-1" />
<%= button_label(@action) %>
</button>
<%= live_patch "Cancel", to: @return_to, class: "button-base button-outlined-gray" %>
</div>
</div>
"""
end
defp message(%{action: "close_all"} = assigns) do
~H"""
<p class="text-gray-700">
Are you sure you want to close <%= pluralize(length(@selected_sessions), "session", "sessions") %>?
<%= if not_persisted_count(@selected_sessions) > 0 do %>
<br/>
<span class="font-medium">Important:</span>
<%= pluralize(
not_persisted_count(@selected_sessions),
"notebook is not persisted and its content may be lost.",
"notebooks are not persisted and their content may be lost."
) %>
<% end %>
</p>
"""
end
defp message(%{action: "disconnect"} = assigns) do
~H"""
<p class="text-gray-700">
Are you sure you want to disconnect <%= pluralize(length(@selected_sessions), "session", "sessions") %>?
</p>
"""
end
@impl true
def handle_event("close_all", %{}, socket) do
socket.assigns.selected_sessions
|> Enum.each(&Livebook.Session.close(&1.pid))
{:noreply, push_patch(socket, to: socket.assigns.return_to, replace: true)}
end
def handle_event("disconnect", %{}, socket) do
socket.assigns.selected_sessions
|> Enum.reject(&(&1.memory_usage.runtime == nil))
|> Enum.each(&Livebook.Session.disconnect_runtime(&1.pid))
{:noreply, push_patch(socket, to: socket.assigns.return_to, replace: true)}
end
defp button_label("close_all"), do: "Close sessions"
defp button_label("disconnect"), do: "Disconnect runtime"
defp title("close_all"), do: "Close sessions"
defp title("disconnect"), do: "Disconnect runtime"
defp not_persisted_count(selected_sessions) do
Enum.count(selected_sessions, &(!&1.file))
end
end

View file

@ -32,35 +32,43 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do
@impl true
def render(assigns) do
~H"""
<div>
<form id="bulk-action-form" phx-submit="bulk_action">
<div class="mb-4 flex items-center md:items-end justify-between">
<h2 class="uppercase font-semibold text-gray-500 text-sm md:text-base">
Running sessions (<%= length(@sessions) %>)
</h2>
<div class="flex flex-row">
<.memory_info />
<.menu id="sessions-order-menu">
<:toggle>
<button class="button-base button-outlined-gray px-4 py-1">
<span><%= order_by_label(@order_by) %></span>
<.remix_icon icon="arrow-down-s-line" class="text-lg leading-none align-middle ml-1" />
</button>
</:toggle>
<:content>
<%= for order_by <- ["date", "title", "memory"] do %>
<button class={"menu-item #{if order_by == @order_by, do: "text-gray-900", else: "text-gray-500"}"}
role="menuitem"
phx-click={JS.push("set_order", value: %{order_by: order_by}, target: @myself)}>
<.remix_icon icon={order_by_icon(order_by)} />
<span class="font-medium"><%= order_by_label(order_by) %></span>
<h2 class="uppercase font-semibold text-gray-500 text-sm md:text-base">
Running sessions (<%= length(@sessions) %>)
</h2>
</div>
<div class="flex flex-row">
<.memory_info />
<%= if @sessions != [] do %>
<.edit_sessions sessions={@sessions} socket={@socket}/>
<% end %>
<.menu id="sessions-order-menu">
<:toggle>
<button class="w-28 button-base button-outlined-gray px-4 py-1 flex justify-between items-center"
type="button">
<span><%= order_by_label(@order_by) %></span>
<.remix_icon icon="arrow-down-s-line" class="text-lg leading-none align-middle ml-1" />
</button>
<% end %>
</:content>
</.menu>
</:toggle>
<:content>
<%= for order_by <- ["date", "title", "memory"] do %>
<button class={"menu-item #{if order_by == @order_by, do: "text-gray-900", else: "text-gray-500"}"}
type="button"
role="menuitem"
phx-click={JS.push("set_order", value: %{order_by: order_by}, target: @myself)}>
<.remix_icon icon={order_by_icon(order_by)} />
<span class="font-medium"><%= order_by_label(order_by) %></span>
</button>
<% end %>
</:content>
</.menu>
</div>
</div>
<.session_list sessions={@sessions} socket={@socket} show_autosave_note?={@show_autosave_note?} />
</div>
<.session_list sessions={@sessions} socket={@socket}
show_autosave_note?={@show_autosave_note?} />
</form>
"""
end
@ -93,6 +101,12 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do
<%= for session <- @sessions do %>
<div class="py-4 flex items-center border-b border-gray-300"
data-test-session-id={session.id}>
<div id={"#{session.id}-checkbox"} phx-update="ignore">
<input type="checkbox" name="session_ids[]" value={session.id}
class="checkbox-base hidden mr-3"
data-element="bulk-edit-member"
phx-click={JS.dispatch("lb:session_list:on_selection_change")}>
</div>
<div class="grow flex flex-col items-start">
<%= live_redirect session.notebook_name,
to: Routes.session_path(@socket, :page, session.id),
@ -113,12 +127,13 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do
</div>
<.menu id={"session-#{session.id}-menu"}>
<:toggle>
<button class="icon-button" aria-label="open session menu">
<button class="icon-button" aria-label="open session menu" type="button">
<.remix_icon icon="more-2-fill" class="text-xl" />
</button>
</:toggle>
<:content>
<button class="menu-item text-gray-500"
type="button"
role="menuitem"
phx-click="fork_session"
phx-value-id={session.id}>
@ -132,6 +147,15 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do
<.remix_icon icon="dashboard-2-line" />
<span class="font-medium">See on Dashboard</span>
</a>
<button class="menu-item text-gray-500"
type="button"
disabled={!session.memory_usage.runtime}
role="menuitem"
phx-click={toggle_edit(:off) |> JS.push("disconnect_runtime")}
phx-value-id={session.id}>
<.remix_icon icon="shut-down-line" />
<span class="font-medium">Disconnect runtime</span>
</button>
<%= live_patch to: Routes.home_path(@socket, :close_session, session.id),
class: "menu-item text-red-600",
role: "menuitem" do %>
@ -171,6 +195,49 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do
"""
end
defp edit_sessions(assigns) do
~H"""
<div class="mx-4 mr-2 text-gray-600 flex flex-row gap-1">
<.menu id="edit-sessions">
<:toggle>
<button id="toggle-edit" class="w-28 button-base button-outlined-gray px-4 pl-2 py-1"
phx-click={toggle_edit(:on)} type="button">
<.remix_icon icon="list-check-2" class="text-lg leading-none align-middle ml-1" />
<span>Edit</span>
</button>
<button class="hidden w-28 button-base button-outlined-gray px-4 py-1 flex justify-between items-center"
data-element="bulk-edit-member"
type="button">
<span>Actions</span>
<.remix_icon icon="arrow-down-s-line" class="text-lg leading-none align-middle ml-1" />
</button>
</:toggle>
<:content>
<button class="menu-item text-gray-600" phx-click={toggle_edit(:off)} type="button">
<.remix_icon icon="close-line" />
<span class="font-medium">Cancel</span>
</button>
<button class="menu-item text-gray-600" phx-click={select_all()} type="button">
<.remix_icon icon="checkbox-multiple-line" />
<span class="font-medium">Select all</span>
</button>
<button class="menu-item text-gray-600" name="disconnect" type="button"
phx-click={set_action("disconnect")}>
<.remix_icon icon="shut-down-line" />
<span class="font-medium">Disconnect runtime</span>
</button>
<button class="menu-item text-red-600" name="close_all" type="button"
phx-click={set_action("close_all")}>
<.remix_icon icon="close-circle-line" />
<span class="font-medium">Close sessions</span>
</button>
<input id="bulk-action-input" class="hidden" type="text" name="action"/>
</:content>
</.menu>
</div>
"""
end
@impl true
def handle_event("set_order", %{"order_by" => order_by}, socket) do
sessions = sort_sessions(socket.assigns.sessions, order_by)
@ -182,6 +249,19 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do
time_words <> " ago"
end
def toggle_edit(:on) do
JS.remove_class("hidden", to: "[data-element='bulk-edit-member']")
|> JS.add_class("hidden", to: "#toggle-edit")
|> JS.dispatch("lb:session_list:on_selection_change")
end
def toggle_edit(:off) do
JS.add_class("hidden", to: "[data-element='bulk-edit-member']")
|> JS.remove_class("hidden", to: "#toggle-edit")
|> JS.dispatch("lb:uncheck", to: "[name='session_ids[]']")
|> JS.dispatch("lb:session_list:on_selection_change")
end
defp order_by_label("date"), do: "Date"
defp order_by_label("title"), do: "Title"
defp order_by_label("memory"), do: "Memory"
@ -206,4 +286,14 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do
defp total_runtime_memory(%{memory_usage: %{runtime: nil}}), do: 0
defp total_runtime_memory(%{memory_usage: %{runtime: %{total: total}}}), do: total
defp select_all() do
JS.dispatch("lb:check", to: "[name='session_ids[]']")
|> JS.dispatch("lb:session_list:on_selection_change")
end
defp set_action(action) do
JS.dispatch("lb:set_value", to: "#bulk-action-input", detail: %{value: action})
|> JS.dispatch("submit", to: "#bulk-action-form")
end
end

View file

@ -35,6 +35,7 @@ defmodule LivebookWeb.Router do
live "/home/user-profile", HomeLive, :user
live "/home/import/:tab", HomeLive, :import
live "/home/sessions/:session_id/close", HomeLive, :close_session
live "/home/sessions/edit_sessions/:action", HomeLive, :edit_sessions
live "/settings", SettingsLive, :page
live "/settings/user-profile", SettingsLive, :user

View file

@ -3,6 +3,8 @@ defmodule LivebookWeb.HelpersTest do
alias LivebookWeb.Helpers
doctest Helpers
describe "names_to_html_ids/1" do
test "title case" do
assert(Helpers.names_to_html_ids(["Title of a Section"]) == ["title-of-a-section"])

View file

@ -29,7 +29,7 @@ defmodule LivebookWeb.HomeLiveTest do
path = Path.expand("../../../lib", __DIR__) <> "/"
view
|> element("form")
|> element(~s{form[phx-change="set_path"]})
|> render_change(%{path: path})
# Render the view separately to make sure it received the :set_file event
@ -42,7 +42,7 @@ defmodule LivebookWeb.HomeLiveTest do
path = test_notebook_path("basic")
view
|> element("form")
|> element(~s{form[phx-change="set_path"]})
|> render_change(%{path: Path.dirname(path) <> "/"})
view
@ -62,7 +62,7 @@ defmodule LivebookWeb.HomeLiveTest do
{:ok, view, _} = live(conn, "/")
view
|> element("form")
|> element(~s{form[phx-change="set_path"]})
|> render_change(%{path: tmp_dir <> "/"})
assert view
@ -76,7 +76,7 @@ defmodule LivebookWeb.HomeLiveTest do
path = File.cwd!() |> Path.join("nonexistent.livemd")
view
|> element("form")
|> element(~s{form[phx-change="set_path"]})
|> render_change(%{path: path})
assert view
@ -94,7 +94,7 @@ defmodule LivebookWeb.HomeLiveTest do
File.chmod!(path, 0o444)
view
|> element("form")
|> element(~s{form[phx-change="set_path"]})
|> render_change(%{path: tmp_dir <> "/"})
view
@ -165,11 +165,40 @@ defmodule LivebookWeb.HomeLiveTest do
|> render_click()
view
|> element(~s{button}, "Close session")
|> element(~s{button[role=button]}, "Close session")
|> render_click()
refute render(view) =~ session.id
end
test "close all selected sessions using bulk action", %{conn: conn} do
{:ok, session1} = Sessions.create_session()
{:ok, session2} = Sessions.create_session()
{:ok, session3} = Sessions.create_session()
{:ok, view, _} = live(conn, "/")
assert render(view) =~ session1.id
assert render(view) =~ session2.id
assert render(view) =~ session3.id
view
|> form("#bulk-action-form", %{
"action" => "close_all",
"session_ids" => [session1.id, session2.id, session3.id]
})
|> render_submit()
assert render(view) =~ "Are you sure you want to close 3 sessions?"
view
|> element(~s{button[role="button"]}, "Close sessions")
|> render_click()
refute render(view) =~ session1.id
refute render(view) =~ session2.id
refute render(view) =~ session3.id
end
end
test "link to introductory notebook correctly creates a new session", %{conn: conn} do