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:
Jonatan Kłosko 2021-05-03 20:03:19 +02:00 committed by GitHub
parent ade9fe5e0e
commit 5c8e117800
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 982 additions and 96 deletions

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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
View 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;
}

View file

@ -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)));
}

View file

@ -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) {

View 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;

View file

@ -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

View file

@ -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
View 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
View 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

View file

@ -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() <> "/"

View file

@ -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, &section_to_view(&1, data))
}
end

View file

@ -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>

View 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

View 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

View 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

View file

@ -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

View file

@ -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}

View file

@ -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))

View 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

View 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

View file

@ -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

View 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