mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-11-19 07:18:37 +08:00
Setup user profiles (#253)
* Add initial user config modal * Assign user ids * Update session data to hold user ids * Get users list for specific ids * Render user avatar * User update * Refactor user changes * Subscribe to individual user updates * Show users in side panel * Add sidebar to homepage * Don't generate the same color twice in a row * Add documentation notes * Fix tests * Add tests * Keep users in session data * Rename color to hex_color
This commit is contained in:
parent
ade9fe5e0e
commit
5c8e117800
24 changed files with 982 additions and 96 deletions
|
|
@ -74,6 +74,10 @@
|
|||
@apply w-full px-3 py-2 bg-gray-50 text-sm border border-gray-200 rounded-lg placeholder-gray-400 text-gray-600;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
@apply mb-0.5 text-sm text-gray-800 font-medium;
|
||||
}
|
||||
|
||||
.switch-button {
|
||||
@apply relative inline-block w-14 h-7 mr-2 select-none transition;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,13 +73,28 @@ solely client-side operations.
|
|||
@apply bg-blue-300;
|
||||
}
|
||||
|
||||
[data-element="session"]:not([data-js-sections-panel-expanded])
|
||||
[data-element="sections-panel"] {
|
||||
[data-element="session"]:not([data-js-side-panel-content])
|
||||
[data-element="side-panel"] {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
[data-element="session"][data-js-sections-panel-expanded]
|
||||
[data-element="sections-panel-toggle"] {
|
||||
[data-element="session"]:not([data-js-side-panel-content="sections-list"])
|
||||
[data-element="sections-list"] {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
[data-element="session"]:not([data-js-side-panel-content="users-list"])
|
||||
[data-element="users-list"] {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
[data-element="session"][data-js-side-panel-content="sections-list"]
|
||||
[data-element="sections-list-toggle"] {
|
||||
@apply text-gray-50 bg-gray-700;
|
||||
}
|
||||
|
||||
[data-element="session"][data-js-side-panel-content="users-list"]
|
||||
[data-element="users-list-toggle"] {
|
||||
@apply text-gray-50 bg-gray-700;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ import FocusOnUpdate from "./focus_on_update";
|
|||
import ScrollOnUpdate from "./scroll_on_update";
|
||||
import VirtualizedLines from "./virtualized_lines";
|
||||
import Menu from "./menu";
|
||||
import UserForm from "./user_form";
|
||||
import morphdomCallbacks from "./morphdom_callbacks";
|
||||
import { loadUserData } from "./lib/user";
|
||||
|
||||
const hooks = {
|
||||
ContentEditable,
|
||||
|
|
@ -28,6 +30,7 @@ const hooks = {
|
|||
ScrollOnUpdate,
|
||||
VirtualizedLines,
|
||||
Menu,
|
||||
UserForm,
|
||||
};
|
||||
|
||||
const csrfToken = document
|
||||
|
|
@ -35,7 +38,13 @@ const csrfToken = document
|
|||
.getAttribute("content");
|
||||
|
||||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
params: { _csrf_token: csrfToken },
|
||||
params: (liveViewName) => {
|
||||
return {
|
||||
_csrf_token: csrfToken,
|
||||
// Pass the most recent user data to the LiveView in `connect_params`
|
||||
user_data: loadUserData(),
|
||||
};
|
||||
},
|
||||
hooks: hooks,
|
||||
dom: morphdomCallbacks,
|
||||
});
|
||||
|
|
|
|||
43
assets/js/lib/user.js
Normal file
43
assets/js/lib/user.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { decodeBase64, encodeBase64 } from "./utils";
|
||||
|
||||
const USER_DATA_COOKIE = "user_data";
|
||||
|
||||
/**
|
||||
* Stores user data in the `"user_data"` cookie.
|
||||
*/
|
||||
export function storeUserData(userData) {
|
||||
const json = JSON.stringify(userData);
|
||||
const encoded = encodeBase64(json);
|
||||
setCookie(USER_DATA_COOKIE, encoded, 157_680_000); // 5 years
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads user data from the `"user_data"` cookie.
|
||||
*/
|
||||
export function loadUserData() {
|
||||
const encoded = getCookieValue(USER_DATA_COOKIE);
|
||||
if (encoded) {
|
||||
const json = decodeBase64(encoded);
|
||||
return JSON.parse(json);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getCookieValue(key) {
|
||||
const cookie = document.cookie
|
||||
.split("; ")
|
||||
.find((cookie) => cookie.startsWith(`${key}=`));
|
||||
|
||||
if (cookie) {
|
||||
const value = cookie.replace(`${key}=`, "");
|
||||
return value;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setCookie(key, value, maxAge) {
|
||||
const cookie = `${key}=${value};max-age=${maxAge};path=/`;
|
||||
document.cookie = cookie;
|
||||
}
|
||||
|
|
@ -49,3 +49,17 @@ export function smoothlyScrollToElement(element) {
|
|||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a UTF8 string into base64 encoding.
|
||||
*/
|
||||
export function encodeBase64(string) {
|
||||
return btoa(unescape(encodeURIComponent(string)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms base64 encoding into UTF8 string.
|
||||
*/
|
||||
export function decodeBase64(binary) {
|
||||
return decodeURIComponent(escape(atob(binary)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,8 +47,12 @@ const Session = {
|
|||
handleSectionListClick(this, event);
|
||||
});
|
||||
|
||||
getSectionsPanelToggle().addEventListener("click", (event) => {
|
||||
toggleSectionsPanel(this);
|
||||
getSectionsListToggle().addEventListener("click", (event) => {
|
||||
toggleSectionsList(this);
|
||||
});
|
||||
|
||||
getUsersListToggle().addEventListener("click", (event) => {
|
||||
toggleUsersList(this);
|
||||
});
|
||||
|
||||
getNotebook().addEventListener("scroll", (event) => {
|
||||
|
|
@ -179,7 +183,9 @@ function handleDocumentKeyDown(hook, event) {
|
|||
} else if (keyBuffer.tryMatch(["e", "j"])) {
|
||||
queueChildCellsEvaluation(hook);
|
||||
} else if (keyBuffer.tryMatch(["s", "s"])) {
|
||||
toggleSectionsPanel(hook);
|
||||
toggleSectionsList(hook);
|
||||
} else if (keyBuffer.tryMatch(["s", "u"])) {
|
||||
toggleUsersList(hook);
|
||||
} else if (keyBuffer.tryMatch(["s", "r"])) {
|
||||
showNotebookRuntimeSettings(hook);
|
||||
} else if (keyBuffer.tryMatch(["e", "x"])) {
|
||||
|
|
@ -336,8 +342,20 @@ function updateSectionListHighlight() {
|
|||
|
||||
// User action handlers (mostly keybindings)
|
||||
|
||||
function toggleSectionsPanel(hook) {
|
||||
hook.el.toggleAttribute("data-js-sections-panel-expanded");
|
||||
function toggleSectionsList(hook) {
|
||||
if (hook.el.getAttribute("data-js-side-panel-content") === "sections-list") {
|
||||
hook.el.removeAttribute("data-js-side-panel-content");
|
||||
} else {
|
||||
hook.el.setAttribute("data-js-side-panel-content", "sections-list");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleUsersList(hook) {
|
||||
if (hook.el.getAttribute("data-js-side-panel-content") === "users-list") {
|
||||
hook.el.removeAttribute("data-js-side-panel-content");
|
||||
} else {
|
||||
hook.el.setAttribute("data-js-side-panel-content", "users-list");
|
||||
}
|
||||
}
|
||||
|
||||
function showNotebookRuntimeSettings(hook) {
|
||||
|
|
@ -639,8 +657,12 @@ function getNotebook() {
|
|||
return document.querySelector(`[data-element="notebook"]`);
|
||||
}
|
||||
|
||||
function getSectionsPanelToggle() {
|
||||
return document.querySelector(`[data-element="sections-panel-toggle"]`);
|
||||
function getSectionsListToggle() {
|
||||
return document.querySelector(`[data-element="sections-list-toggle"]`);
|
||||
}
|
||||
|
||||
function getUsersListToggle() {
|
||||
return document.querySelector(`[data-element="users-list-toggle"]`);
|
||||
}
|
||||
|
||||
function cancelEvent(event) {
|
||||
|
|
|
|||
20
assets/js/user_form/index.js
Normal file
20
assets/js/user_form/index.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { storeUserData } from "../lib/user";
|
||||
|
||||
/**
|
||||
* A hook for the user profile form.
|
||||
*
|
||||
* On submit this hook saves the new data into cookie.
|
||||
* This cookie serves as a backup and can be used to restore
|
||||
* user data if the server is restarted.
|
||||
*/
|
||||
const UserForm = {
|
||||
mounted() {
|
||||
this.el.addEventListener("submit", (event) => {
|
||||
const name = this.el.data_name.value;
|
||||
const hex_color = this.el.data_hex_color.value;
|
||||
storeUserData({ name, hex_color });
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default UserForm;
|
||||
|
|
@ -16,6 +16,7 @@ defmodule Livebook.Session do
|
|||
|
||||
alias Livebook.Session.{Data, FileGuard}
|
||||
alias Livebook.{Utils, Notebook, Delta, Runtime, LiveMarkdown}
|
||||
alias Livebook.Users.User
|
||||
alias Livebook.Notebook.{Cell, Section}
|
||||
|
||||
@type state :: %{
|
||||
|
|
@ -82,9 +83,9 @@ defmodule Livebook.Session do
|
|||
keep in sync with the server by subscribing to the `sessions:id` topic
|
||||
and receiving operations to apply.
|
||||
"""
|
||||
@spec register_client(id(), pid()) :: Data.t()
|
||||
def register_client(session_id, pid) do
|
||||
GenServer.call(name(session_id), {:register_client, pid})
|
||||
@spec register_client(id(), pid(), User.t()) :: Data.t()
|
||||
def register_client(session_id, client_pid, user) do
|
||||
GenServer.call(name(session_id), {:register_client, client_pid, user})
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -317,10 +318,10 @@ defmodule Livebook.Session do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:register_client, pid}, _from, state) do
|
||||
Process.monitor(pid)
|
||||
def handle_call({:register_client, client_pid, user}, _from, state) do
|
||||
Process.monitor(client_pid)
|
||||
|
||||
state = handle_operation(state, {:client_join, pid})
|
||||
state = handle_operation(state, {:client_join, client_pid, user})
|
||||
|
||||
{:reply, state.data, state}
|
||||
end
|
||||
|
|
@ -474,7 +475,7 @@ defmodule Livebook.Session do
|
|||
|
||||
def handle_info({:DOWN, _, :process, pid, _}, state) do
|
||||
state =
|
||||
if pid in state.data.client_pids do
|
||||
if Map.has_key?(state.data.clients_map, pid) do
|
||||
handle_operation(state, {:client_leave, pid})
|
||||
else
|
||||
state
|
||||
|
|
@ -505,6 +506,11 @@ defmodule Livebook.Session do
|
|||
{:noreply, maybe_save_notebook(state)}
|
||||
end
|
||||
|
||||
def handle_info({:user_change, user}, state) do
|
||||
operation = {:update_user, self(), user}
|
||||
{:noreply, handle_operation(state, operation)}
|
||||
end
|
||||
|
||||
def handle_info(_message, state), do: {:noreply, state}
|
||||
|
||||
@impl true
|
||||
|
|
@ -606,6 +612,24 @@ defmodule Livebook.Session do
|
|||
state
|
||||
end
|
||||
|
||||
defp after_operation(state, prev_state, {:client_join, _client_pid, user}) do
|
||||
unless Map.has_key?(prev_state.data.users_map, user.id) do
|
||||
Phoenix.PubSub.subscribe(Livebook.PubSub, "users:#{user.id}")
|
||||
end
|
||||
|
||||
state
|
||||
end
|
||||
|
||||
defp after_operation(state, prev_state, {:client_leave, client_pid}) do
|
||||
user_id = prev_state.data.clients_map[client_pid]
|
||||
|
||||
unless Map.has_key?(state.data.users_map, user_id) do
|
||||
Phoenix.PubSub.unsubscribe(Livebook.PubSub, "users:#{user_id}")
|
||||
end
|
||||
|
||||
state
|
||||
end
|
||||
|
||||
defp after_operation(state, _prev_state, _operation), do: state
|
||||
|
||||
defp handle_actions(state, actions) do
|
||||
|
|
|
|||
|
|
@ -23,10 +23,12 @@ defmodule Livebook.Session.Data do
|
|||
:deleted_sections,
|
||||
:deleted_cells,
|
||||
:runtime,
|
||||
:client_pids
|
||||
:clients_map,
|
||||
:users_map
|
||||
]
|
||||
|
||||
alias Livebook.{Notebook, Evaluator, Delta, Runtime, JSInterop}
|
||||
alias Livebook.Users.User
|
||||
alias Livebook.Notebook.{Cell, Section}
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
|
|
@ -38,7 +40,8 @@ defmodule Livebook.Session.Data do
|
|||
deleted_sections: list(Section.t()),
|
||||
deleted_cells: list(Cell.t()),
|
||||
runtime: Runtime.t() | nil,
|
||||
client_pids: list(pid())
|
||||
clients_map: %{pid() => User.id()},
|
||||
users_map: %{User.id() => User.t()}
|
||||
}
|
||||
|
||||
@type section_info :: %{
|
||||
|
|
@ -61,6 +64,8 @@ defmodule Livebook.Session.Data do
|
|||
@type cell_validity_status :: :fresh | :evaluated | :stale | :aborted
|
||||
@type cell_evaluation_status :: :ready | :queued | :evaluating
|
||||
|
||||
@type client :: {User.id(), pid()}
|
||||
|
||||
@type index :: non_neg_integer()
|
||||
|
||||
# Note that all operations carry the pid of whatever
|
||||
|
|
@ -85,8 +90,9 @@ defmodule Livebook.Session.Data do
|
|||
| {:cancel_cell_evaluation, pid(), Cell.id()}
|
||||
| {:set_notebook_name, pid(), String.t()}
|
||||
| {:set_section_name, pid(), Section.id(), String.t()}
|
||||
| {:client_join, pid()}
|
||||
| {:client_join, pid(), User.t()}
|
||||
| {:client_leave, pid()}
|
||||
| {:update_user, pid(), User.t()}
|
||||
| {:apply_cell_delta, pid(), Cell.id(), Delta.t(), cell_revision()}
|
||||
| {:report_cell_revision, pid(), Cell.id(), cell_revision()}
|
||||
| {:set_cell_metadata, pid(), Cell.id(), Cell.metadata()}
|
||||
|
|
@ -114,7 +120,8 @@ defmodule Livebook.Session.Data do
|
|||
deleted_sections: [],
|
||||
deleted_cells: [],
|
||||
runtime: nil,
|
||||
client_pids: []
|
||||
clients_map: %{},
|
||||
users_map: %{}
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -128,7 +135,7 @@ defmodule Livebook.Session.Data do
|
|||
for section <- notebook.sections,
|
||||
cell <- section.cells,
|
||||
into: %{},
|
||||
do: {cell.id, new_cell_info(cell, [])}
|
||||
do: {cell.id, new_cell_info(cell, %{})}
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -334,22 +341,33 @@ defmodule Livebook.Session.Data do
|
|||
end
|
||||
end
|
||||
|
||||
def apply_operation(data, {:client_join, pid}) do
|
||||
with false <- pid in data.client_pids do
|
||||
def apply_operation(data, {:client_join, client_pid, user}) do
|
||||
with false <- Map.has_key?(data.clients_map, client_pid) do
|
||||
data
|
||||
|> with_actions()
|
||||
|> client_join(pid)
|
||||
|> client_join(client_pid, user)
|
||||
|> wrap_ok()
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
def apply_operation(data, {:client_leave, pid}) do
|
||||
with true <- pid in data.client_pids do
|
||||
def apply_operation(data, {:client_leave, client_pid}) do
|
||||
with true <- Map.has_key?(data.clients_map, client_pid) do
|
||||
data
|
||||
|> with_actions()
|
||||
|> client_leave(pid)
|
||||
|> client_leave(client_pid)
|
||||
|> wrap_ok()
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
def apply_operation(data, {:update_user, _client_pid, user}) do
|
||||
with true <- Map.has_key?(data.users_map, user.id) do
|
||||
data
|
||||
|> with_actions()
|
||||
|> update_user(user)
|
||||
|> wrap_ok()
|
||||
else
|
||||
_ -> :error
|
||||
|
|
@ -360,7 +378,7 @@ defmodule Livebook.Session.Data do
|
|||
with {:ok, cell, _} <- Notebook.fetch_cell_and_section(data.notebook, cell_id),
|
||||
cell_info <- data.cell_infos[cell.id],
|
||||
true <- 0 < revision and revision <= cell_info.revision + 1,
|
||||
true <- client_pid in data.client_pids do
|
||||
true <- Map.has_key?(data.clients_map, client_pid) do
|
||||
data
|
||||
|> with_actions()
|
||||
|> apply_delta(client_pid, cell, delta, revision)
|
||||
|
|
@ -375,7 +393,7 @@ defmodule Livebook.Session.Data do
|
|||
with {:ok, cell, _} <- Notebook.fetch_cell_and_section(data.notebook, cell_id),
|
||||
cell_info <- data.cell_infos[cell.id],
|
||||
true <- 0 < revision and revision <= cell_info.revision,
|
||||
true <- client_pid in data.client_pids do
|
||||
true <- Map.has_key?(data.clients_map, client_pid) do
|
||||
data
|
||||
|> with_actions()
|
||||
|> report_revision(client_pid, cell, revision)
|
||||
|
|
@ -438,7 +456,7 @@ defmodule Livebook.Session.Data do
|
|||
data_actions
|
||||
|> set!(
|
||||
notebook: Notebook.insert_cell(data.notebook, section_id, index, cell),
|
||||
cell_infos: Map.put(data.cell_infos, cell.id, new_cell_info(cell, data.client_pids))
|
||||
cell_infos: Map.put(data.cell_infos, cell.id, new_cell_info(cell, data.clients_map))
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -682,23 +700,42 @@ defmodule Livebook.Session.Data do
|
|||
|> set!(notebook: Notebook.update_section(data.notebook, section.id, &%{&1 | name: name}))
|
||||
end
|
||||
|
||||
defp client_join({data, _} = data_actions, pid) do
|
||||
defp client_join({data, _} = data_actions, client_pid, user) do
|
||||
data_actions
|
||||
|> set!(client_pids: [pid | data.client_pids])
|
||||
|> set!(
|
||||
clients_map: Map.put(data.clients_map, client_pid, user.id),
|
||||
users_map: Map.put(data.users_map, user.id, user)
|
||||
)
|
||||
|> update_every_cell_info(fn info ->
|
||||
put_in(info.revision_by_client_pid[pid], info.revision)
|
||||
put_in(info.revision_by_client_pid[client_pid], info.revision)
|
||||
end)
|
||||
end
|
||||
|
||||
defp client_leave({data, _} = data_actions, pid) do
|
||||
defp client_leave({data, _} = data_actions, client_pid) do
|
||||
{user_id, clients_map} = Map.pop(data.clients_map, client_pid)
|
||||
|
||||
users_map =
|
||||
if user_id in Map.values(clients_map) do
|
||||
data.users_map
|
||||
else
|
||||
Map.delete(data.users_map, user_id)
|
||||
end
|
||||
|
||||
data_actions
|
||||
|> set!(client_pids: List.delete(data.client_pids, pid))
|
||||
|> set!(
|
||||
clients_map: clients_map,
|
||||
users_map: users_map
|
||||
)
|
||||
|> update_every_cell_info(fn info ->
|
||||
{_, info} = pop_in(info.revision_by_client_pid[pid])
|
||||
{_, info} = pop_in(info.revision_by_client_pid[client_pid])
|
||||
purge_deltas(info)
|
||||
end)
|
||||
end
|
||||
|
||||
defp update_user({data, _} = data_actions, user) do
|
||||
set!(data_actions, users_map: Map.put(data.users_map, user.id, user))
|
||||
end
|
||||
|
||||
defp apply_delta({data, _} = data_actions, client_pid, cell, delta, revision) do
|
||||
info = data.cell_infos[cell.id]
|
||||
|
||||
|
|
@ -777,7 +814,9 @@ defmodule Livebook.Session.Data do
|
|||
}
|
||||
end
|
||||
|
||||
defp new_cell_info(cell, client_pids) do
|
||||
defp new_cell_info(cell, clients_map) do
|
||||
client_pids = Map.keys(clients_map)
|
||||
|
||||
%{
|
||||
revision: 0,
|
||||
deltas: [],
|
||||
|
|
|
|||
20
lib/livebook/users.ex
Normal file
20
lib/livebook/users.ex
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
defmodule Livebook.Users do
|
||||
@moduledoc false
|
||||
|
||||
alias Livebook.Users.User
|
||||
|
||||
@doc """
|
||||
Notifies interested processes about user data change.
|
||||
|
||||
Broadcasts `{:user_change, user}` message under the `"user:{id}"` topic.
|
||||
"""
|
||||
@spec broadcast_change(User.t()) :: :ok
|
||||
def broadcast_change(user) do
|
||||
broadcast_user_message(user.id, {:user_change, user})
|
||||
:ok
|
||||
end
|
||||
|
||||
defp broadcast_user_message(user_id, message) do
|
||||
Phoenix.PubSub.broadcast(Livebook.PubSub, "users:#{user_id}", message)
|
||||
end
|
||||
end
|
||||
109
lib/livebook/users/user.ex
Normal file
109
lib/livebook/users/user.ex
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
defmodule Livebook.Users.User do
|
||||
@moduledoc false
|
||||
|
||||
# Represents a Livebook user.
|
||||
#
|
||||
# Livebook users are not regular web app accounts,
|
||||
# but rather ephemeral data about the clients
|
||||
# using the app. Every person using Livebook
|
||||
# can provide data like name and cursor color
|
||||
# to improve visibility during collaboration.
|
||||
|
||||
defstruct [:id, :name, :hex_color]
|
||||
|
||||
alias Livebook.Utils
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: id(),
|
||||
name: String.t() | nil,
|
||||
hex_color: hex_color()
|
||||
}
|
||||
|
||||
@type id :: Utils.id()
|
||||
@type hex_color :: String.t()
|
||||
|
||||
@doc """
|
||||
Generates a new user.
|
||||
"""
|
||||
@spec new() :: t()
|
||||
def new() do
|
||||
%__MODULE__{
|
||||
id: Utils.random_id(),
|
||||
name: nil,
|
||||
hex_color: random_hex_color()
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates `attrs` and returns an updated user.
|
||||
|
||||
In case of validation errors `{:error, errors, user}` tuple
|
||||
is returned, where `user` is partially updated by using
|
||||
only the valid attributes.
|
||||
"""
|
||||
@spec change(t(), %{binary() => any()}) :: {:ok, t()} | {:error, list(String.t()), t()}
|
||||
def change(user, attrs \\ %{}) do
|
||||
{user, []}
|
||||
|> change_name(attrs)
|
||||
|> change_hex_color(attrs)
|
||||
|> case do
|
||||
{user, []} -> {:ok, user}
|
||||
{user, errors} -> {:error, errors, user}
|
||||
end
|
||||
end
|
||||
|
||||
defp change_name({user, errors}, %{"name" => ""}) do
|
||||
{%{user | name: nil}, errors}
|
||||
end
|
||||
|
||||
defp change_name({user, errors}, %{"name" => name}) do
|
||||
{%{user | name: name}, errors}
|
||||
end
|
||||
|
||||
defp change_name({user, errors}, _attrs), do: {user, errors}
|
||||
|
||||
defp change_hex_color({user, errors}, %{"hex_color" => hex_color}) do
|
||||
if hex_color_valid?(hex_color) do
|
||||
{%{user | hex_color: hex_color}, errors}
|
||||
else
|
||||
{user, [{:hex_color, "not a valid color"} | errors]}
|
||||
end
|
||||
end
|
||||
|
||||
defp change_hex_color({user, errors}, _attrs), do: {user, errors}
|
||||
|
||||
defp hex_color_valid?(hex_color), do: hex_color =~ ~r/^#[0-9a-fA-F]{6}$/
|
||||
|
||||
@doc """
|
||||
Returns a random hex color for a user.
|
||||
|
||||
## Options
|
||||
|
||||
* `:except` - a list of colors to omit
|
||||
"""
|
||||
def random_hex_color(opts \\ []) do
|
||||
colors = [
|
||||
# red
|
||||
"#F87171",
|
||||
# yellow
|
||||
"#FBBF24",
|
||||
# green
|
||||
"#6EE7B7",
|
||||
# blue
|
||||
"#60A5FA",
|
||||
# purple
|
||||
"#A78BFA",
|
||||
# pink
|
||||
"#F472B6",
|
||||
# salmon
|
||||
"#FA8072",
|
||||
# mat green
|
||||
"#9ED9CC"
|
||||
]
|
||||
|
||||
except = opts[:except] || []
|
||||
colors = colors -- except
|
||||
|
||||
Enum.random(colors)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,23 +1,41 @@
|
|||
defmodule LivebookWeb.HomeLive do
|
||||
use LivebookWeb, :live_view
|
||||
|
||||
import LivebookWeb.UserHelpers
|
||||
|
||||
alias Livebook.{SessionSupervisor, Session, LiveMarkdown, Notebook}
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
def mount(_params, %{"current_user_id" => current_user_id}, socket) do
|
||||
if connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions")
|
||||
Phoenix.PubSub.subscribe(Livebook.PubSub, "users:#{current_user_id}")
|
||||
end
|
||||
|
||||
current_user = build_current_user(current_user_id, socket)
|
||||
session_summaries = sort_session_summaries(SessionSupervisor.get_session_summaries())
|
||||
|
||||
{:ok, assign(socket, path: default_path(), session_summaries: session_summaries)}
|
||||
{:ok,
|
||||
assign(socket,
|
||||
current_user: current_user,
|
||||
path: default_path(),
|
||||
session_summaries: session_summaries
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
<div class="flex flex-grow h-full">
|
||||
<div class="w-16 flex flex-col items-center space-y-5 px-3 py-7 bg-gray-900">
|
||||
<div class="flex-grow"></div>
|
||||
<span class="tooltip right distant" aria-label="User profile">
|
||||
<%= live_patch to: Routes.home_path(@socket, :user),
|
||||
class: "text-gray-400 rounded-xl h-8 w-8 flex items-center justify-center" do %>
|
||||
<%= render_user_avatar(@current_user, class: "h-full w-full", text_class: "text-xs") %>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-grow px-6 py-8 overflow-y-auto">
|
||||
<div class="max-w-screen-lg w-full mx-auto p-4 pt-0 pb-8 flex flex-col items-center space-y-4">
|
||||
<div class="w-full flex flex-col space-y-2 items-center sm:flex-row sm:space-y-0 sm:justify-between sm:pb-4 pb-8 border-b border-gray-200">
|
||||
|
|
@ -99,6 +117,14 @@ defmodule LivebookWeb.HomeLive do
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @live_action == :user do %>
|
||||
<%= live_modal @socket, LivebookWeb.UserComponent,
|
||||
id: :user_modal,
|
||||
modal_class: "w-full max-w-sm",
|
||||
user: @current_user,
|
||||
return_to: Routes.home_path(@socket, :page) %>
|
||||
<% end %>
|
||||
|
||||
<%= if @live_action == :close_session do %>
|
||||
<%= live_modal @socket, LivebookWeb.HomeLive.CloseSessionComponent,
|
||||
id: :close_session_modal,
|
||||
|
|
@ -181,6 +207,13 @@ defmodule LivebookWeb.HomeLive do
|
|||
create_session(socket, notebook: notebook)
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
{:user_change, %{id: id} = user},
|
||||
%{assigns: %{current_user: %{id: id}}} = socket
|
||||
) do
|
||||
{:noreply, assign(socket, :current_user, user)}
|
||||
end
|
||||
|
||||
def handle_info(_message, socket), do: {:noreply, socket}
|
||||
|
||||
defp default_path(), do: Livebook.Config.root_path() <> "/"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
defmodule LivebookWeb.SessionLive do
|
||||
use LivebookWeb, :live_view
|
||||
|
||||
import LivebookWeb.UserHelpers
|
||||
|
||||
alias Livebook.{SessionSupervisor, Session, Delta, Notebook, Runtime}
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => session_id}, _session, socket) do
|
||||
def mount(%{"id" => session_id}, %{"current_user_id" => current_user_id}, socket) do
|
||||
if SessionSupervisor.session_exists?(session_id) do
|
||||
current_user = build_current_user(current_user_id, socket)
|
||||
|
||||
data =
|
||||
if connected?(socket) do
|
||||
data = Session.register_client(session_id, self())
|
||||
data = Session.register_client(session_id, self(), current_user)
|
||||
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
|
||||
Phoenix.PubSub.subscribe(Livebook.PubSub, "users:#{current_user_id}")
|
||||
|
||||
data
|
||||
else
|
||||
|
|
@ -26,6 +31,7 @@ defmodule LivebookWeb.SessionLive do
|
|||
platform: platform,
|
||||
session_id: session_id,
|
||||
session_pid: session_pid,
|
||||
current_user: current_user,
|
||||
data_view: data_to_view(data)
|
||||
)
|
||||
|> assign_private(data: data)
|
||||
|
|
@ -63,15 +69,20 @@ defmodule LivebookWeb.SessionLive do
|
|||
id="session"
|
||||
data-element="session"
|
||||
phx-hook="Session">
|
||||
<div class="flex flex-col items-center space-y-5 px-3 py-7 bg-gray-900">
|
||||
<div class="w-16 flex flex-col items-center space-y-5 px-3 py-7 bg-gray-900">
|
||||
<%= live_patch to: Routes.home_path(@socket, :page) do %>
|
||||
<img src="/logo.png" height="40" width="40" alt="livebook" />
|
||||
<% end %>
|
||||
<span class="tooltip right distant" aria-label="Sections (ss)">
|
||||
<button class="text-2xl text-gray-400 hover:text-gray-50 focus:text-gray-50 rounded-xl h-10 w-10 flex items-center justify-center" data-element="sections-panel-toggle">
|
||||
<button class="text-2xl text-gray-400 hover:text-gray-50 focus:text-gray-50 rounded-xl h-10 w-10 flex items-center justify-center" data-element="sections-list-toggle">
|
||||
<%= remix_icon("booklet-fill") %>
|
||||
</button>
|
||||
</span>
|
||||
<span class="tooltip right distant" aria-label="Connected users (su)">
|
||||
<button class="text-2xl text-gray-400 hover:text-gray-50 focus:text-gray-50 rounded-xl h-10 w-10 flex items-center justify-center" data-element="users-list-toggle">
|
||||
<%= remix_icon("group-fill") %>
|
||||
</button>
|
||||
</span>
|
||||
<span class="tooltip right distant" aria-label="Runtime settings (sr)">
|
||||
<%= live_patch to: Routes.session_path(@socket, :runtime_settings, @session_id),
|
||||
class: "text-gray-400 hover:text-gray-50 focus:text-gray-50 rounded-xl h-10 w-10 flex items-center justify-center #{if(@live_action == :runtime_settings, do: "text-gray-50 bg-gray-700")}" do %>
|
||||
|
|
@ -85,27 +96,55 @@ defmodule LivebookWeb.SessionLive do
|
|||
<%= remix_icon("keyboard-box-fill", class: "text-2xl") %>
|
||||
<% end %>
|
||||
</span>
|
||||
<span class="tooltip right distant" aria-label="User profile">
|
||||
<%= live_patch to: Routes.session_path(@socket, :user, @session_id),
|
||||
class: "text-gray-400 rounded-xl h-8 w-8 flex items-center justify-center" do %>
|
||||
<%= render_user_avatar(@current_user, class: "h-full w-full", text_class: "text-xs") %>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col h-full w-full max-w-xs absolute z-30 top-0 left-[64px] shadow-xl md:static md:shadow-none overflow-y-auto bg-gray-50 border-r border-gray-100 px-6 py-10"
|
||||
data-element="sections-panel">
|
||||
<div class="flex-grow flex flex-col">
|
||||
<h3 class="font-semibold text-gray-800 text-lg">
|
||||
Sections
|
||||
</h3>
|
||||
<div class="mt-4 flex flex-col space-y-4" data-element="section-list">
|
||||
<%= for section_item <- @data_view.sections_items do %>
|
||||
<button class="text-left hover:text-gray-900 text-gray-500"
|
||||
data-element="section-list-item"
|
||||
data-section-id="<%= section_item.id %>">
|
||||
<%= section_item.name %>
|
||||
</button>
|
||||
<% end %>
|
||||
data-element="side-panel">
|
||||
<div data-element="sections-list">
|
||||
<div class="flex-grow flex flex-col">
|
||||
<h3 class="font-semibold text-gray-800 text-lg">
|
||||
Sections
|
||||
</h3>
|
||||
<div class="mt-4 flex flex-col space-y-4" data-element="section-list">
|
||||
<%= for section_item <- @data_view.sections_items do %>
|
||||
<button class="text-left hover:text-gray-900 text-gray-500"
|
||||
data-element="section-list-item"
|
||||
data-section-id="<%= section_item.id %>">
|
||||
<%= section_item.name %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<button class="mt-8 p-8 py-1 text-gray-500 text-sm font-medium rounded-xl border border-gray-400 border-dashed hover:bg-gray-100 inline-flex items-center justify-center space-x-2"
|
||||
phx-click="add_section" >
|
||||
<%= remix_icon("add-line", class: "text-lg align-center") %>
|
||||
<span>New section</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-element="users-list">
|
||||
<div class="flex-grow flex flex-col">
|
||||
<h3 class="font-semibold text-gray-800 text-lg">
|
||||
Users
|
||||
</h3>
|
||||
<h4 class="font text-gray-500 text-sm my-1">
|
||||
<%= length(@data_view.users) %> connected
|
||||
</h4>
|
||||
<div class="mt-4 flex flex-col space-y-4" data-element="section-list">
|
||||
<%= for user <- @data_view.users do %>
|
||||
<div class="flex space-x-2 items-center">
|
||||
<%= render_user_avatar(user, class: "h-7 w-7 flex-shrink-0", text_class: "text-xs") %>
|
||||
<span class="text-gray-500">
|
||||
<%= user.name || "Anonymous" %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<button class="mt-8 p-8 py-1 text-gray-500 text-sm font-medium rounded-xl border border-gray-400 border-dashed hover:bg-gray-100 inline-flex items-center justify-center space-x-2"
|
||||
phx-click="add_section" >
|
||||
<%= remix_icon("add-line", class: "text-lg align-center") %>
|
||||
<span>New section</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow overflow-y-auto" data-element="notebook">
|
||||
|
|
@ -170,6 +209,14 @@ defmodule LivebookWeb.SessionLive do
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @live_action == :user do %>
|
||||
<%= live_modal @socket, LivebookWeb.UserComponent,
|
||||
id: :user_modal,
|
||||
modal_class: "w-full max-w-sm",
|
||||
user: @current_user,
|
||||
return_to: Routes.session_path(@socket, :page, @session_id) %>
|
||||
<% end %>
|
||||
|
||||
<%= if @live_action == :runtime_settings do %>
|
||||
<%= live_modal @socket, LivebookWeb.SessionLive.RuntimeComponent,
|
||||
id: :runtime_settings_modal,
|
||||
|
|
@ -495,6 +542,13 @@ defmodule LivebookWeb.SessionLive do
|
|||
{:noreply, push_event(socket, "completion_response", payload)}
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
{:user_change, %{id: id} = user},
|
||||
%{assigns: %{current_user: %{id: id}}} = socket
|
||||
) do
|
||||
{:noreply, assign(socket, :current_user, user)}
|
||||
end
|
||||
|
||||
def handle_info(_message, socket), do: {:noreply, socket}
|
||||
|
||||
defp after_operation(socket, _prev_socket, {:insert_section, client_pid, _index, section_id}) do
|
||||
|
|
@ -606,6 +660,11 @@ defmodule LivebookWeb.SessionLive do
|
|||
for section <- data.notebook.sections do
|
||||
%{id: section.id, name: section.name}
|
||||
end,
|
||||
users:
|
||||
data.clients_map
|
||||
|> Map.values()
|
||||
|> Enum.map(&data.users_map[&1])
|
||||
|> Enum.sort_by(& &1.name),
|
||||
section_views: Enum.map(data.notebook.sections, §ion_to_view(&1, data))
|
||||
}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -42,12 +42,12 @@ defmodule LivebookWeb.SessionLive.AttachedLive do
|
|||
<%= f = form_for :data, "#", phx_submit: "init", phx_change: "validate" %>
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div>
|
||||
<div class="mb-0.5 text-sm text-gray-800 font-medium">Name</div>
|
||||
<div class="input-label">Name</div>
|
||||
<%= text_input f, :name, value: @data["name"], class: "input",
|
||||
placeholder: if(Livebook.Config.shortnames?, do: "test", else: "test@127.0.0.1") %>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-0.5 text-sm text-gray-800 font-medium">Cookie</div>
|
||||
<div class="input-label">Cookie</div>
|
||||
<%= text_input f, :cookie, value: @data["cookie"], class: "input", placeholder: "mycookie" %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
98
lib/livebook_web/live/user_component.ex
Normal file
98
lib/livebook_web/live/user_component.ex
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
defmodule LivebookWeb.UserComponent do
|
||||
use LivebookWeb, :live_component
|
||||
|
||||
import LivebookWeb.UserHelpers
|
||||
|
||||
alias Livebook.Users.User
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket = assign(socket, assigns)
|
||||
user = socket.assigns.user
|
||||
|
||||
{:ok, assign(socket, data: user_to_data(user), valid: true, preview_user: user)}
|
||||
end
|
||||
|
||||
defp user_to_data(user) do
|
||||
%{"name" => user.name || "", "hex_color" => user.hex_color}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
<div class="p-6 flex flex-col space-y-5">
|
||||
<h3 class="text-2xl font-semibold text-gray-800">
|
||||
User profile
|
||||
</h3>
|
||||
<div class="flex justify-center">
|
||||
<%= render_user_avatar(@preview_user, class: "h-20 w-20", text_class: "text-3xl") %>
|
||||
</div>
|
||||
<%= f = form_for :data, "#",
|
||||
id: "user_form",
|
||||
phx_target: @myself,
|
||||
phx_submit: "save",
|
||||
phx_change: "validate",
|
||||
phx_hook: "UserForm" %>
|
||||
<div class="flex flex-col space-y-5">
|
||||
<div>
|
||||
<div class="input-label">Display name</div>
|
||||
<%= text_input f, :name, value: @data["name"], class: "input", spellcheck: "false" %>
|
||||
</div>
|
||||
<div>
|
||||
<div class="input-label">Cursor color</div>
|
||||
<div class="flex space-x-4 items-center">
|
||||
<div class="border-[3px] rounded-lg p-1 flex justify-center items-center"
|
||||
style="border-color: <%= @preview_user.hex_color %>">
|
||||
<div class="rounded h-5 w-5"
|
||||
style="background-color: <%= @preview_user.hex_color %>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-grow">
|
||||
<%= text_input f, :hex_color, value: @data["hex_color"], class: "input", spellcheck: "false", maxlength: 7 %>
|
||||
<%= tag :button, class: "icon-button absolute right-2 top-1",
|
||||
type: "button",
|
||||
phx_click: "randomize_color",
|
||||
phx_target: @myself %>
|
||||
<%= remix_icon("refresh-line", class: "text-xl") %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%= tag :button, class: "button button-blue flex space-x-1 justify-center items-center",
|
||||
type: "submit",
|
||||
disabled: not @valid %>
|
||||
<%= remix_icon("save-line") %>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("randomize_color", %{}, socket) do
|
||||
data = %{
|
||||
socket.assigns.data
|
||||
| "hex_color" => User.random_hex_color(except: [socket.assigns.preview_user.hex_color])
|
||||
}
|
||||
|
||||
handle_event("validate", %{"data" => data}, socket)
|
||||
end
|
||||
|
||||
def handle_event("validate", %{"data" => data}, socket) do
|
||||
{valid, user} =
|
||||
case User.change(socket.assigns.user, data) do
|
||||
{:ok, user} -> {true, user}
|
||||
{:error, _errors, user} -> {false, user}
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, data: data, valid: valid, preview_user: user)}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"data" => data}, socket) do
|
||||
{:ok, user} = User.change(socket.assigns.user, data)
|
||||
Livebook.Users.broadcast_change(user)
|
||||
{:noreply, push_patch(socket, to: socket.assigns.return_to)}
|
||||
end
|
||||
end
|
||||
61
lib/livebook_web/live/user_helpers.ex
Normal file
61
lib/livebook_web/live/user_helpers.ex
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
defmodule LivebookWeb.UserHelpers do
|
||||
import Phoenix.LiveView
|
||||
import Phoenix.LiveView.Helpers
|
||||
|
||||
alias Livebook.Users.User
|
||||
|
||||
@doc """
|
||||
Renders user avatar,
|
||||
|
||||
## Options
|
||||
|
||||
* `:class` - class added to the avatar box
|
||||
|
||||
* `:text_class` - class added to the avatar text
|
||||
"""
|
||||
def render_user_avatar(user, opts \\ []) do
|
||||
assigns = %{
|
||||
name: user.name,
|
||||
hex_color: user.hex_color,
|
||||
class: Keyword.get(opts, :class, "w-full h-full"),
|
||||
text_class: Keyword.get(opts, :text_class)
|
||||
}
|
||||
|
||||
~L"""
|
||||
<div class="rounded-full <%= @class %> flex items-center justify-center" style="background-color: <%= @hex_color %>">
|
||||
<div class="<%= @text_class %> text-gray-100 font-semibold">
|
||||
<%= avatar_text(@name) %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp avatar_text(nil), do: "?"
|
||||
|
||||
defp avatar_text(name) do
|
||||
name
|
||||
|> String.split()
|
||||
|> Enum.map(&String.at(&1, 0))
|
||||
|> Enum.map(&String.upcase/1)
|
||||
|> case do
|
||||
[initial] -> initial
|
||||
initials -> List.first(initials) <> List.last(initials)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds `Livebook.Users.User` with the given user id.
|
||||
|
||||
Uses `user_data` from socket `connect_params` as initial
|
||||
attributes if the socket is connected.
|
||||
"""
|
||||
def build_current_user(current_user_id, socket) do
|
||||
connect_params = get_connect_params(socket) || %{}
|
||||
user_data = connect_params["user_data"] || %{}
|
||||
|
||||
case User.change(%{User.new() | id: current_user_id}, user_data) do
|
||||
{:ok, user} -> user
|
||||
{:error, _errors, user} -> user
|
||||
end
|
||||
end
|
||||
end
|
||||
58
lib/livebook_web/plugs/user_plug.ex
Normal file
58
lib/livebook_web/plugs/user_plug.ex
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
defmodule LivebookWeb.UserPlug do
|
||||
@moduledoc false
|
||||
|
||||
# Initializes the session and cookies with user-related info.
|
||||
#
|
||||
# The first time someone visits Livebook
|
||||
# this plug stores a new random user id
|
||||
# in the session under `:current_user_id`.
|
||||
#
|
||||
# Additionally the cookies are checked for the presence
|
||||
# of `"user_data"` and if there is none, a new user
|
||||
# attributes are stored there. This makes sure
|
||||
# the client-side can always access some `"user_data"`
|
||||
# for `connect_params` of the socket connection.
|
||||
|
||||
@behaviour Plug
|
||||
|
||||
import Plug.Conn
|
||||
|
||||
alias Livebook.Users.User
|
||||
|
||||
@impl true
|
||||
def init(opts), do: opts
|
||||
|
||||
@impl true
|
||||
def call(conn, _opts) do
|
||||
conn
|
||||
|> ensure_current_user_id()
|
||||
|> ensure_user_data()
|
||||
end
|
||||
|
||||
defp ensure_current_user_id(conn) do
|
||||
if get_session(conn, :current_user_id) do
|
||||
conn
|
||||
else
|
||||
user_id = Livebook.Utils.random_id()
|
||||
put_session(conn, :current_user_id, user_id)
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_user_data(conn) do
|
||||
if Map.has_key?(conn.req_cookies, "user_data") do
|
||||
conn
|
||||
else
|
||||
user_data = user_data(User.new())
|
||||
encoded = user_data |> Jason.encode!() |> Base.encode64()
|
||||
# Set `http_only` to `false`, so that it can be accessed on the client
|
||||
# Set expiration in 5 years
|
||||
put_resp_cookie(conn, "user_data", encoded, http_only: false, max_age: 157_680_000)
|
||||
end
|
||||
end
|
||||
|
||||
defp user_data(user) do
|
||||
user
|
||||
|> Map.from_struct()
|
||||
|> Map.delete(:id)
|
||||
end
|
||||
end
|
||||
|
|
@ -13,15 +13,18 @@ defmodule LivebookWeb.Router do
|
|||
|
||||
pipeline :auth do
|
||||
plug LivebookWeb.AuthPlug
|
||||
plug LivebookWeb.UserPlug
|
||||
end
|
||||
|
||||
scope "/", LivebookWeb do
|
||||
pipe_through [:browser, :auth]
|
||||
|
||||
live "/", HomeLive, :page
|
||||
live "/home/user-profile", HomeLive, :user
|
||||
live "/home/import/:tab", HomeLive, :import
|
||||
live "/home/sessions/:session_id/close", HomeLive, :close_session
|
||||
live "/sessions/:id", SessionLive, :page
|
||||
live "/sessions/:id/user-profile", SessionLive, :user
|
||||
live "/sessions/:id/shortcuts", SessionLive, :shortcuts
|
||||
live "/sessions/:id/settings/runtime", SessionLive, :runtime_settings
|
||||
live "/sessions/:id/settings/file", SessionLive, :file_settings
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ defmodule Livebook.Session.DataTest do
|
|||
|
||||
alias Livebook.Session.Data
|
||||
alias Livebook.{Delta, Notebook}
|
||||
alias Livebook.Users.User
|
||||
|
||||
describe "new/1" do
|
||||
test "called with no arguments defaults to a blank notebook" do
|
||||
|
|
@ -72,7 +73,7 @@ defmodule Livebook.Session.DataTest do
|
|||
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, client_pid},
|
||||
{:client_join, client_pid, User.new()},
|
||||
{:insert_section, self(), 0, "s1"}
|
||||
])
|
||||
|
||||
|
|
@ -1193,37 +1194,43 @@ defmodule Livebook.Session.DataTest do
|
|||
|
||||
describe "apply_operation/2 given :client_join" do
|
||||
test "returns an error if the given process is already a client" do
|
||||
user = User.new()
|
||||
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, self()}
|
||||
{:client_join, self(), user}
|
||||
])
|
||||
|
||||
operation = {:client_join, self()}
|
||||
operation = {:client_join, self(), user}
|
||||
assert :error = Data.apply_operation(data, operation)
|
||||
end
|
||||
|
||||
test "adds the given process to the client list" do
|
||||
test "adds the given process and user to their corresponding maps" do
|
||||
client_pid = self()
|
||||
%{id: user_id} = user = User.new()
|
||||
data = Data.new()
|
||||
|
||||
operation = {:client_join, client_pid}
|
||||
assert {:ok, %{client_pids: [^client_pid]}, []} = Data.apply_operation(data, operation)
|
||||
operation = {:client_join, client_pid, user}
|
||||
|
||||
assert {:ok, %{clients_map: %{^client_pid => ^user_id}, users_map: %{^user_id => ^user}},
|
||||
[]} = Data.apply_operation(data, operation)
|
||||
end
|
||||
|
||||
test "adds new entry to the cell revisions map for the client with the latest revision" do
|
||||
client1_pid = IEx.Helpers.pid(0, 0, 0)
|
||||
user = User.new()
|
||||
delta1 = Delta.new() |> Delta.insert("cats")
|
||||
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, client1_pid},
|
||||
{:client_join, client1_pid, user},
|
||||
{:insert_section, self(), 0, "s1"},
|
||||
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
|
||||
{:apply_cell_delta, client1_pid, "c1", delta1, 1}
|
||||
])
|
||||
|
||||
client2_pid = IEx.Helpers.pid(0, 0, 1)
|
||||
operation = {:client_join, client2_pid}
|
||||
operation = {:client_join, client2_pid, user}
|
||||
|
||||
assert {:ok,
|
||||
%{
|
||||
|
|
@ -1240,14 +1247,44 @@ defmodule Livebook.Session.DataTest do
|
|||
assert :error = Data.apply_operation(data, operation)
|
||||
end
|
||||
|
||||
test "removes the given process from the client list" do
|
||||
test "removes the given process from the client map" do
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, self()}
|
||||
{:client_join, self(), User.new()}
|
||||
])
|
||||
|
||||
operation = {:client_leave, self()}
|
||||
assert {:ok, %{client_pids: []}, []} = Data.apply_operation(data, operation)
|
||||
|
||||
empty_map = %{}
|
||||
assert {:ok, %{clients_map: ^empty_map}, []} = Data.apply_operation(data, operation)
|
||||
end
|
||||
|
||||
test "removes the corresponding user from users map if it has no more client processes" do
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, self(), User.new()}
|
||||
])
|
||||
|
||||
operation = {:client_leave, self()}
|
||||
|
||||
empty_map = %{}
|
||||
assert {:ok, %{users_map: ^empty_map}, []} = Data.apply_operation(data, operation)
|
||||
end
|
||||
|
||||
test "leaves the corresponding user in users map if it has more client processes" do
|
||||
%{id: user_id} = user = User.new()
|
||||
client1_pid = IEx.Helpers.pid(0, 0, 0)
|
||||
client2_pid = IEx.Helpers.pid(0, 0, 1)
|
||||
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, client1_pid, user},
|
||||
{:client_join, client2_pid, user}
|
||||
])
|
||||
|
||||
operation = {:client_leave, client2_pid}
|
||||
|
||||
assert {:ok, %{users_map: %{^user_id => ^user}}, []} = Data.apply_operation(data, operation)
|
||||
end
|
||||
|
||||
test "removes an entry in the the cell revisions map for the client and purges deltas" do
|
||||
|
|
@ -1258,8 +1295,8 @@ defmodule Livebook.Session.DataTest do
|
|||
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, client1_pid},
|
||||
{:client_join, client2_pid},
|
||||
{:client_join, client1_pid, User.new()},
|
||||
{:client_join, client2_pid, User.new()},
|
||||
{:insert_section, self(), 0, "s1"},
|
||||
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
|
||||
{:apply_cell_delta, client1_pid, "c1", delta1, 1}
|
||||
|
|
@ -1278,11 +1315,35 @@ defmodule Livebook.Session.DataTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "apply_operation/2 given :update_user" do
|
||||
test "returns an error if the given user is not a client" do
|
||||
data = Data.new()
|
||||
|
||||
operation = {:update_user, self(), User.new()}
|
||||
assert :error = Data.apply_operation(data, operation)
|
||||
end
|
||||
|
||||
test "updates users map" do
|
||||
%{id: user_id} = user = User.new()
|
||||
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, self(), user}
|
||||
])
|
||||
|
||||
updated_user = %{user | name: "Jake Peralta"}
|
||||
operation = {:update_user, self(), updated_user}
|
||||
|
||||
assert {:ok, %{users_map: %{^user_id => ^updated_user}}, []} =
|
||||
Data.apply_operation(data, operation)
|
||||
end
|
||||
end
|
||||
|
||||
describe "apply_operation/2 given :apply_cell_delta" do
|
||||
test "returns an error given invalid cell id" do
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, self()}
|
||||
{:client_join, self(), User.new()}
|
||||
])
|
||||
|
||||
operation = {:apply_cell_delta, self(), "nonexistent", Delta.new(), 1}
|
||||
|
|
@ -1304,7 +1365,7 @@ defmodule Livebook.Session.DataTest do
|
|||
test "returns an error given invalid revision" do
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, self()},
|
||||
{:client_join, self(), User.new()},
|
||||
{:insert_section, self(), 0, "s1"},
|
||||
{:insert_cell, self(), "s1", 0, :elixir, "c1"}
|
||||
])
|
||||
|
|
@ -1318,7 +1379,7 @@ defmodule Livebook.Session.DataTest do
|
|||
test "updates cell source according to the given delta" do
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, self()},
|
||||
{:client_join, self(), User.new()},
|
||||
{:insert_section, self(), 0, "s1"},
|
||||
{:insert_cell, self(), "s1", 0, :elixir, "c1"}
|
||||
])
|
||||
|
|
@ -1340,7 +1401,7 @@ defmodule Livebook.Session.DataTest do
|
|||
test "updates cell digest based on the new content" do
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, self()},
|
||||
{:client_join, self(), User.new()},
|
||||
{:insert_section, self(), 0, "s1"},
|
||||
{:insert_cell, self(), "s1", 0, :elixir, "c1"}
|
||||
])
|
||||
|
|
@ -1364,8 +1425,8 @@ defmodule Livebook.Session.DataTest do
|
|||
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, client1_pid},
|
||||
{:client_join, client2_pid},
|
||||
{:client_join, client1_pid, User.new()},
|
||||
{:client_join, client2_pid, User.new()},
|
||||
{:insert_section, self(), 0, "s1"},
|
||||
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
|
||||
{:apply_cell_delta, client1_pid, "c1", delta1, 1}
|
||||
|
|
@ -1393,8 +1454,8 @@ defmodule Livebook.Session.DataTest do
|
|||
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, client1_pid},
|
||||
{:client_join, client2_pid},
|
||||
{:client_join, client1_pid, User.new()},
|
||||
{:client_join, client2_pid, User.new()},
|
||||
{:insert_section, self(), 0, "s1"},
|
||||
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
|
||||
{:apply_cell_delta, client1_pid, "c1", delta1, 1}
|
||||
|
|
@ -1414,7 +1475,7 @@ defmodule Livebook.Session.DataTest do
|
|||
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, client_pid},
|
||||
{:client_join, client_pid, User.new()},
|
||||
{:insert_section, self(), 0, "s1"},
|
||||
{:insert_cell, self(), "s1", 0, :elixir, "c1"}
|
||||
])
|
||||
|
|
@ -1434,8 +1495,8 @@ defmodule Livebook.Session.DataTest do
|
|||
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, client1_pid},
|
||||
{:client_join, client2_pid},
|
||||
{:client_join, client1_pid, User.new()},
|
||||
{:client_join, client2_pid, User.new()},
|
||||
{:insert_section, self(), 0, "s1"},
|
||||
{:insert_cell, self(), "s1", 0, :elixir, "c1"}
|
||||
])
|
||||
|
|
@ -1454,7 +1515,7 @@ defmodule Livebook.Session.DataTest do
|
|||
test "returns an error given invalid cell id" do
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, self()}
|
||||
{:client_join, self(), User.new()}
|
||||
])
|
||||
|
||||
operation = {:report_cell_revision, self(), "nonexistent", 1}
|
||||
|
|
@ -1467,7 +1528,7 @@ defmodule Livebook.Session.DataTest do
|
|||
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, client1_pid},
|
||||
{:client_join, client1_pid, User.new()},
|
||||
{:insert_section, self(), 0, "s1"},
|
||||
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
|
||||
{:apply_cell_delta, client1_pid, "c1", Delta.new(insert: "cats"), 1}
|
||||
|
|
@ -1480,7 +1541,7 @@ defmodule Livebook.Session.DataTest do
|
|||
test "returns an error given invalid revision" do
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, self()},
|
||||
{:client_join, self(), User.new()},
|
||||
{:insert_section, self(), 0, "s1"},
|
||||
{:insert_cell, self(), "s1", 0, :elixir, "c1"}
|
||||
])
|
||||
|
|
@ -1497,8 +1558,8 @@ defmodule Livebook.Session.DataTest do
|
|||
|
||||
data =
|
||||
data_after_operations!([
|
||||
{:client_join, client1_pid},
|
||||
{:client_join, client2_pid},
|
||||
{:client_join, client1_pid, User.new()},
|
||||
{:client_join, client2_pid, User.new()},
|
||||
{:insert_section, self(), 0, "s1"},
|
||||
{:insert_cell, self(), "s1", 0, :elixir, "c1"},
|
||||
{:apply_cell_delta, client1_pid, "c1", delta1, 1}
|
||||
|
|
|
|||
|
|
@ -372,6 +372,18 @@ defmodule Livebook.SessionTest do
|
|||
assert_receive {:info, "runtime node terminated unexpectedly"}
|
||||
end
|
||||
|
||||
test "on user change sends an update operation subscribers", %{session_id: session_id} do
|
||||
user = Livebook.Users.User.new()
|
||||
Session.register_client(session_id, self(), user)
|
||||
|
||||
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
|
||||
|
||||
updated_user = %{user | name: "Jake Peralta"}
|
||||
Livebook.Users.broadcast_change(updated_user)
|
||||
|
||||
assert_receive {:operation, {:update_user, _pid, ^updated_user}}
|
||||
end
|
||||
|
||||
defp start_session(opts \\ []) do
|
||||
session_id = Utils.random_id()
|
||||
{:ok, _} = Session.start_link(Keyword.merge(opts, id: session_id))
|
||||
|
|
|
|||
34
test/livebook/users/user_test.exs
Normal file
34
test/livebook/users/user_test.exs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
defmodule Livebook.Users.UserTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Livebook.Users.User
|
||||
|
||||
describe "change/2" do
|
||||
test "given valid attributes returns and updated user" do
|
||||
user = User.new()
|
||||
attrs = %{"name" => "Jake Peralta", "hex_color" => "#000000"}
|
||||
assert {:ok, %User{name: "Jake Peralta", hex_color: "#000000"}} = User.change(user, attrs)
|
||||
end
|
||||
|
||||
test "given empty name sets name to nil" do
|
||||
user = User.new()
|
||||
attrs = %{"name" => ""}
|
||||
assert {:ok, %User{name: nil}} = User.change(user, attrs)
|
||||
end
|
||||
|
||||
test "given invalid color returns an error" do
|
||||
user = User.new()
|
||||
attrs = %{"hex_color" => "#invalid"}
|
||||
assert {:error, [{:hex_color, "not a valid color"}], _user} = User.change(user, attrs)
|
||||
end
|
||||
|
||||
test "given invalid attribute partially updates the user" do
|
||||
user = User.new()
|
||||
current_hex_color = user.hex_color
|
||||
attrs = %{"hex_color" => "#invalid", "name" => "Jake Peralta"}
|
||||
|
||||
assert {:error, _errors, %User{name: "Jake Peralta", hex_color: ^current_hex_color}} =
|
||||
User.change(user, attrs)
|
||||
end
|
||||
end
|
||||
end
|
||||
17
test/livebook/users_test.exs
Normal file
17
test/livebook/users_test.exs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
defmodule Livebook.UsersTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Livebook.Users
|
||||
alias Livebook.Users.User
|
||||
|
||||
describe "broadcast_change/1" do
|
||||
test "notifies subscribers of user change" do
|
||||
user = User.new()
|
||||
Phoenix.PubSub.subscribe(Livebook.PubSub, "users:#{user.id}")
|
||||
|
||||
Users.broadcast_change(user)
|
||||
|
||||
assert_received {:user_change, ^user}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -3,7 +3,8 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Livebook.{SessionSupervisor, Session, Delta, Runtime}
|
||||
alias Livebook.{SessionSupervisor, Session, Delta, Runtime, Users}
|
||||
alias Livebook.Users.User
|
||||
|
||||
setup do
|
||||
{:ok, session_id} = SessionSupervisor.create_session()
|
||||
|
|
@ -270,6 +271,80 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
assert render(view) =~ "My notebook - fork"
|
||||
end
|
||||
|
||||
describe "connected users" do
|
||||
test "lists connected users", %{conn: conn, session_id: session_id} do
|
||||
user1 = create_user_with_name("Jake Peralta")
|
||||
|
||||
client_pid =
|
||||
spawn_link(fn ->
|
||||
Session.register_client(session_id, self(), user1)
|
||||
|
||||
receive do
|
||||
:stop -> :ok
|
||||
end
|
||||
end)
|
||||
|
||||
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
|
||||
|
||||
assert render(view) =~ "Jake Peralta"
|
||||
|
||||
send(client_pid, :stop)
|
||||
end
|
||||
|
||||
test "updates users list whenever a user joins or leaves",
|
||||
%{conn: conn, session_id: session_id} do
|
||||
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
|
||||
|
||||
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
|
||||
|
||||
user1 = create_user_with_name("Jake Peralta")
|
||||
|
||||
client_pid =
|
||||
spawn_link(fn ->
|
||||
Session.register_client(session_id, self(), user1)
|
||||
|
||||
receive do
|
||||
:stop -> :ok
|
||||
end
|
||||
end)
|
||||
|
||||
assert_receive {:operation, {:client_join, ^client_pid, _user}}
|
||||
assert render(view) =~ "Jake Peralta"
|
||||
|
||||
send(client_pid, :stop)
|
||||
assert_receive {:operation, {:client_leave, ^client_pid}}
|
||||
refute render(view) =~ "Jake Peralta"
|
||||
end
|
||||
|
||||
test "updates users list whenever a user changes his data",
|
||||
%{conn: conn, session_id: session_id} do
|
||||
user1 = create_user_with_name("Jake Peralta")
|
||||
|
||||
client_pid =
|
||||
spawn_link(fn ->
|
||||
Session.register_client(session_id, self(), user1)
|
||||
|
||||
receive do
|
||||
:stop -> :ok
|
||||
end
|
||||
end)
|
||||
|
||||
{:ok, view, _} = live(conn, "/sessions/#{session_id}")
|
||||
|
||||
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session_id}")
|
||||
|
||||
assert render(view) =~ "Jake Peralta"
|
||||
|
||||
Users.broadcast_change(%{user1 | name: "Raymond Holt"})
|
||||
assert_receive {:operation, {:update_user, _pid, _user}}
|
||||
|
||||
refute render(view) =~ "Jake Peralta"
|
||||
assert render(view) =~ "Raymond Holt"
|
||||
|
||||
send(client_pid, :stop)
|
||||
end
|
||||
end
|
||||
|
||||
# Helpers
|
||||
|
||||
defp wait_for_session_update(session_id) do
|
||||
|
|
@ -297,4 +372,9 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
|
||||
cell.id
|
||||
end
|
||||
|
||||
defp create_user_with_name(name) do
|
||||
{:ok, user} = User.new() |> User.change(%{"name" => name})
|
||||
user
|
||||
end
|
||||
end
|
||||
|
|
|
|||
51
test/livebook_web/plugs/user_plug_test.exs
Normal file
51
test/livebook_web/plugs/user_plug_test.exs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
defmodule LivebookWeb.UserPlugTest do
|
||||
use ExUnit.Case, async: true
|
||||
use Plug.Test
|
||||
|
||||
defp call(conn) do
|
||||
LivebookWeb.UserPlug.call(conn, LivebookWeb.UserPlug.init([]))
|
||||
end
|
||||
|
||||
test "given no user id in the session, generates a new user id" do
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> init_test_session(%{})
|
||||
|> call()
|
||||
|
||||
assert get_session(conn, :current_user_id) != nil
|
||||
end
|
||||
|
||||
test "keeps user id in the session if present" do
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> init_test_session(%{current_user_id: "valid_user_id"})
|
||||
|> call()
|
||||
|
||||
assert get_session(conn, :current_user_id) != nil
|
||||
end
|
||||
|
||||
test "given no user_data cookie, generates and stores new data" do
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> init_test_session(%{})
|
||||
|> fetch_cookies()
|
||||
|> call()
|
||||
|> fetch_cookies()
|
||||
|
||||
assert conn.cookies["user_data"] != nil
|
||||
end
|
||||
|
||||
test "keeps user_data cookie if present" do
|
||||
cookie_value = ~s/{"name":"Jake Peralta","hex_color":"#000000"}/
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> init_test_session(%{})
|
||||
|> put_req_cookie("user_data", cookie_value)
|
||||
|> fetch_cookies()
|
||||
|> call()
|
||||
|> fetch_cookies()
|
||||
|
||||
assert conn.cookies["user_data"] == cookie_value
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue