Move app settings to a modal and add explanations (#1914)

Co-authored-by: José Valim <jose.valim@dashbit.co>
This commit is contained in:
Jonatan Kłosko 2023-05-21 21:21:10 +02:00 committed by GitHub
parent 7340c6b204
commit d1ba07e0ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 370 additions and 304 deletions

View file

@ -14,6 +14,7 @@ defmodule LivebookWeb.FormComponents do
attr :value, :any
attr :errors, :list, default: []
attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form"
attr :help, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global, include: ~w(autocomplete readonly disabled)
@ -22,7 +23,7 @@ defmodule LivebookWeb.FormComponents do
assigns = assigns_from_field(assigns)
~H"""
<.field_wrapper id={@id} name={@name} label={@label} errors={@errors}>
<.field_wrapper id={@id} name={@name} label={@label} errors={@errors} help={@help}>
<input
type="text"
name={@name}
@ -44,6 +45,7 @@ defmodule LivebookWeb.FormComponents do
attr :value, :any
attr :errors, :list, default: []
attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form"
attr :help, :string, default: nil
attr :resizable, :boolean, default: false
@ -53,7 +55,7 @@ defmodule LivebookWeb.FormComponents do
assigns = assigns_from_field(assigns)
~H"""
<.field_wrapper id={@id} name={@name} label={@label} errors={@errors}>
<.field_wrapper id={@id} name={@name} label={@label} errors={@errors} help={@help}>
<textarea
id={@id || @name}
name={@name}
@ -91,6 +93,7 @@ defmodule LivebookWeb.FormComponents do
attr :value, :any
attr :errors, :list, default: []
attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form"
attr :help, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global, include: ~w(autocomplete readonly disabled)
@ -99,7 +102,7 @@ defmodule LivebookWeb.FormComponents do
assigns = assigns_from_field(assigns)
~H"""
<.field_wrapper id={@id} name={@name} label={@label} errors={@errors}>
<.field_wrapper id={@id} name={@name} label={@label} errors={@errors} help={@help}>
<.with_password_toggle id={@id <> "-toggle"}>
<input
type="password"
@ -123,6 +126,7 @@ defmodule LivebookWeb.FormComponents do
attr :value, :any
attr :errors, :list, default: []
attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form"
attr :help, :string, default: nil
attr :randomize, JS, default: %JS{}
attr :rest, :global
@ -131,7 +135,7 @@ defmodule LivebookWeb.FormComponents do
assigns = assigns_from_field(assigns)
~H"""
<.field_wrapper id={@id} name={@name} label={@label} errors={@errors}>
<.field_wrapper id={@id} name={@name} label={@label} errors={@errors} help={@help}>
<div class="flex space-x-4 items-center">
<div
class="border-[3px] rounded-lg p-1 flex justify-center items-center"
@ -168,11 +172,11 @@ defmodule LivebookWeb.FormComponents do
attr :value, :any
attr :errors, :list, default: []
attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form"
attr :help, :string, default: nil
attr :disabled, :boolean, default: false
attr :checked_value, :string, default: "true"
attr :unchecked_value, :string, default: "false"
attr :tooltip, :string, default: nil
attr :rest, :global
@ -182,12 +186,9 @@ defmodule LivebookWeb.FormComponents do
~H"""
<div phx-feedback-for={@name} class={[@errors != [] && "show-errors"]}>
<div class="flex items-center gap-1 sm:gap-3 justify-between">
<span
:if={@label}
class={["text-gray-700", @tooltip && "tooltip top"]}
data-tooltip={@tooltip}
>
<span :if={@label} class="text-gray-700 flex gap-1 items-center">
<%= @label %>
<.help :if={@help} text={@help} />
</span>
<label class={[
"relative inline-block w-14 h-7 select-none",
@ -227,6 +228,7 @@ defmodule LivebookWeb.FormComponents do
attr :value, :any
attr :errors, :list, default: []
attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form"
attr :help, :string, default: nil
attr :checked_value, :string, default: "true"
attr :unchecked_value, :string, default: "false"
@ -249,7 +251,10 @@ defmodule LivebookWeb.FormComponents do
checked={to_string(@value) == @checked_value}
{@rest}
/>
<span :if={@label} class="text-gray-700"><%= @label %></span>
<span :if={@label} class="text-gray-700 flex gap-1 items-center">
<%= @label %>
<.help :if={@help} text={@help} />
</span>
</label>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
@ -265,6 +270,7 @@ defmodule LivebookWeb.FormComponents do
attr :value, :any
attr :errors, :list, default: []
attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form"
attr :help, :string, default: nil
attr :options, :list, default: [], doc: "a list of `{value, description}` tuples"
@ -275,7 +281,7 @@ defmodule LivebookWeb.FormComponents do
~H"""
<div phx-feedback-for={@name} class={[@errors != [] && "show-errors"]}>
<.label :if={@label} for={@id}><%= @label %></.label>
<.label :if={@label} for={@id} help={@help}><%= @label %></.label>
<div class="flex gap-4 text-gray-600">
<label :for={{value, description} <- @options} class="flex items-center gap-2 cursor-pointer">
<input
@ -304,6 +310,7 @@ defmodule LivebookWeb.FormComponents do
attr :value, :any
attr :errors, :list, default: []
attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form"
attr :help, :string, default: nil
attr :options, :list, default: [], doc: "a list of `{value, description}` tuples"
attr :full_width, :boolean, default: false
@ -315,7 +322,7 @@ defmodule LivebookWeb.FormComponents do
~H"""
<div phx-feedback-for={@name} class={[@errors != [] && "show-errors"]}>
<.label :if={@label} for={@id}><%= @label %></.label>
<.label :if={@label} for={@id} help={@help}><%= @label %></.label>
<div class="flex">
<label
:for={{value, description} <- @options}
@ -352,6 +359,7 @@ defmodule LivebookWeb.FormComponents do
attr :value, :any
attr :errors, :list, default: []
attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form"
attr :help, :string, default: nil
attr :rest, :global
@ -359,7 +367,7 @@ defmodule LivebookWeb.FormComponents do
assigns = assigns_from_field(assigns)
~H"""
<.field_wrapper id={@id} name={@name} label={@label} errors={@errors}>
<.field_wrapper id={@id} name={@name} label={@label} errors={@errors} help={@help}>
<div class="flex border bg-gray-50 rounded-lg space-x-4 items-center">
<div id={"#{@id}-picker"} class="flex w-full" phx-hook="EmojiPicker">
<div class="grow p-1 pl-3">
@ -396,6 +404,7 @@ defmodule LivebookWeb.FormComponents do
attr :value, :any
attr :errors, :list, default: []
attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form"
attr :help, :string, default: nil
attr :options, :list, default: []
attr :prompt, :string, default: nil
@ -406,7 +415,7 @@ defmodule LivebookWeb.FormComponents do
assigns = assigns_from_field(assigns)
~H"""
<.field_wrapper id={@id} name={@name} label={@label} errors={@errors}>
<.field_wrapper id={@id} name={@name} label={@label} errors={@errors} help={@help}>
<select id={@id} name={@name} class="input" {@rest}>
<option :if={@prompt} value="" disabled selected><%= @prompt %></option>
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
@ -440,12 +449,13 @@ defmodule LivebookWeb.FormComponents do
attr :name, :any, required: true
attr :label, :string, required: true
attr :errors, :list, required: true
attr :help, :string, required: true
slot :inner_block, required: true
defp field_wrapper(assigns) do
~H"""
<div phx-feedback-for={@name} class={[@errors != [] && "show-errors"]}>
<.label :if={@label} for={@id}><%= @label %></.label>
<.label :if={@label} for={@id} help={@help}><%= @label %></.label>
<%= render_slot(@inner_block) %>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
@ -456,12 +466,14 @@ defmodule LivebookWeb.FormComponents do
Renders a label.
"""
attr :for, :string, default: nil
attr :help, :string, default: nil
slot :inner_block, required: true
def label(assigns) do
~H"""
<label for={@for} class="mb-1 block text-sm text-gray-800 font-medium">
<label for={@for} class="mb-1 block text-sm text-gray-800 font-medium flex items-center gap-1">
<%= render_slot(@inner_block) %>
<.help :if={@help} text={@help} />
</label>
"""
end
@ -479,6 +491,14 @@ defmodule LivebookWeb.FormComponents do
"""
end
defp help(assigns) do
~H"""
<span class="cursor-pointer tooltip top" data-tooltip={@text}>
<.remix_icon icon="question-line" class="text-sm leading-none" />
</span>
"""
end
@doc """
Renders a wrapper around password input with an added visibility
toggle button.

View file

@ -143,7 +143,7 @@ defmodule LivebookWeb.Hub.Edit.PersonalComponent do
:if={@live_action in [:new_secret, :edit_secret]}
id="secrets-modal"
show
width={:big}
width={:medium}
patch={~p"/hub/#{@hub.id}"}
>
<.live_component

View file

@ -429,6 +429,21 @@ defmodule LivebookWeb.SessionLive do
/>
</.modal>
<.modal
:if={@live_action == :app_settings}
id="app-settings-modal"
show
width={:medium}
patch={@self_path}
>
<.live_component
module={LivebookWeb.SessionLive.AppSettingsComponent}
id="app-settings"
session={@session}
settings={@data_view.app_settings}
/>
</.modal>
<.modal
:if={@live_action == :shortcuts}
id="shortcuts-modal"
@ -529,7 +544,7 @@ defmodule LivebookWeb.SessionLive do
) %>
</.modal>
<.modal :if={@live_action == :secrets} id="secrets-modal" show width={:big} patch={@self_path}>
<.modal :if={@live_action == :secrets} id="secrets-modal" show width={:medium} patch={@self_path}>
<.live_component
module={LivebookWeb.SessionLive.SecretsComponent}
id="secrets"
@ -1233,6 +1248,31 @@ defmodule LivebookWeb.SessionLive do
{:noreply, socket}
end
def handle_event("deploy_app", %{}, socket) do
on_confirm = fn socket ->
Livebook.Session.deploy_app(socket.assigns.session.pid)
socket
end
data = socket.private.data
slug = data.notebook.app_settings.slug
slug_taken? = slug != data.deployed_app_slug and Livebook.Apps.exists?(slug)
socket =
if slug_taken? do
confirm(socket, on_confirm,
title: "Deploy app",
description:
"An app with this slug already exists, do you want to deploy a new version?",
confirm_text: "Replace"
)
else
on_confirm.(socket)
end
{:noreply, socket}
end
def handle_event("intellisense_request", %{"cell_id" => cell_id} = params, socket) do
request =
case params do

View file

@ -3,48 +3,48 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
import LivebookWeb.AppHelpers
alias Livebook.Notebook.AppSettings
@impl true
def mount(socket) do
{:ok, assign(socket, deploy_confirmation: false)}
end
@impl true
def update(assigns, socket) do
changeset =
case socket.assigns do
%{changeset: changeset} when changeset.data == assigns.settings -> changeset
_ -> AppSettings.change(assigns.settings)
end
{:ok,
socket
|> assign(assigns)
|> assign(changeset: changeset)}
end
@impl true
def render(assigns) do
~H"""
<div class="flex flex-col">
<div class="flex items-center justify-between">
<h3 class="uppercase text-sm font-semibold text-gray-500">
App settings
App
</h3>
<.app_info_icon />
</div>
<div class="mt-2">
<.app_form
changeset={@changeset}
deploy_confirmation={@deploy_confirmation}
session={@session}
myself={@myself}
/>
<%= if @session.mode == :app do %>
<div class="mt-5 flex flex-col space-y-6">
<span class="text-gray-700 text-sm">
This session is a running app. To deploy a modified version, you can fork it.
</span>
<div>
<button class="button-base button-blue" phx-click="fork_session">
<.remix_icon icon="git-branch-line" />
<span>Fork</span>
</button>
</div>
</div>
<% else %>
<div class="mt-5 flex space-x-2">
<button
class="button-base button-blue"
phx-click="deploy_app"
disabled={not Livebook.Notebook.AppSettings.valid?(@settings)}
>
<.remix_icon icon="rocket-line" class="align-middle mr-1" />
<span>Deploy</span>
</button>
<.link
patch={~p"/sessions/#{@session.id}/settings/app"}
class="button-base button-outlined-gray bg-transparent"
>
Configure
</.link>
</div>
<%= if @app do %>
<h3 class="mt-16 uppercase text-sm font-semibold text-gray-500">
Deployment
<h3 class="mt-10 uppercase text-sm font-semibold text-gray-500">
Latest deployment
</h3>
<div class="mt-2 border border-gray-200 rounded-lg">
<div class="p-4 flex flex-col space-y-3">
@ -73,9 +73,9 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
</span>
</div>
</div>
<div class="mt-2 text-gray-600 font-medium text-sm">
App sessions
</div>
<h3 class="mt-10 uppercase text-sm font-semibold text-gray-500">
Running sessions
</h3>
<div class="mt-2 flex flex-col space-y-4">
<div :for={app_session <- @app.sessions} class="border border-gray-200 rounded-lg">
<div class="p-4 flex flex-col space-y-3">
@ -147,6 +147,7 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
</div>
</div>
<% end %>
<% end %>
</div>
"""
end
@ -169,165 +170,7 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
"""
end
defp app_form(%{session: %{mode: :app}} = assigns) do
~H"""
<div class="mt-5 flex flex-col space-y-6">
<span class="text-gray-700 text-sm">
This session is a running app. To deploy a modified version, you can fork it.
</span>
<div>
<button class="button-base button-blue" phx-click="fork_session">
<.remix_icon icon="git-branch-line" />
<span>Fork</span>
</button>
</div>
</div>
"""
end
defp app_form(%{deploy_confirmation: true} = assigns) do
~H"""
<div class="mt-5">
<span class="text-gray-700 text-sm">
An app with this slug already exists, do you want to deploy a new version?
</span>
<div class="mt-5 flex space-x-2">
<button
class="button-base button-red"
phx-click="deploy_confirmation_confirm"
phx-target={@myself}
>
Replace
</button>
<button
class="button-base button-outlined-gray bg-transparent"
phx-click="deploy_confirmation_cancel"
phx-target={@myself}
>
Cancel
</button>
</div>
</div>
"""
end
defp app_form(assigns) do
~H"""
<.form
:let={f}
for={@changeset}
phx-submit="deploy"
phx-change="validate"
phx-target={@myself}
autocomplete="off"
>
<div class="flex flex-col space-y-4">
<.text_field
field={f[:slug]}
label="Slug"
spellcheck="false"
phx-debounce
class="bg-gray-100"
/>
<.radio_button_group_field
field={f[:multi_session]}
options={[{"false", "Single"}, {"true", "Multi"}]}
label="Session type"
full_width
/>
<.select_field
field={f[:auto_shutdown_ms]}
label="Shutdown after inactivity"
options={[
{"Never", ""},
{"5 seconds", "5000"},
{"1 minute", "60000"},
{"1 hour", "3600000"}
]}
/>
<%= unless Ecto.Changeset.get_field(@changeset, :multi_session) do %>
<.checkbox_field field={f[:zero_downtime]} label="Zero-downtime deployment" />
<% end %>
<%= if Ecto.Changeset.get_field(@changeset, :multi_session) do %>
<.checkbox_field field={f[:show_existing_sessions]} label="List existing sessions" />
<% end %>
<div class="flex flex-col space-y-1">
<.checkbox_field
field={f[:access_type]}
label="Password-protected"
checked_value="protected"
unchecked_value="public"
/>
<%= if Ecto.Changeset.get_field(@changeset, :access_type) == :protected do %>
<.password_field field={f[:password]} spellcheck="false" phx-debounce class="bg-gray-100" />
<% end %>
</div>
<.checkbox_field field={f[:show_source]} label="Show source" />
<.checkbox_field
field={f[:output_type]}
label="Only render rich outputs"
checked_value="rich"
unchecked_value="all"
/>
</div>
<div class="mt-6 flex space-x-2">
<button class="button-base button-blue" type="submit" disabled={not @changeset.valid?}>
Deploy
</button>
<button class="button-base button-outlined-gray bg-transparent" type="reset" name="reset">
Reset
</button>
</div>
</.form>
"""
end
@impl true
def handle_event("validate", %{"_target" => ["reset"]}, socket) do
settings = AppSettings.new()
Livebook.Session.set_app_settings(socket.assigns.session.pid, settings)
{:noreply, assign(socket, changeset: AppSettings.change(settings))}
end
def handle_event("validate", %{"app_settings" => params}, socket) do
changeset =
socket.assigns.settings
|> AppSettings.change(params)
|> Map.put(:action, :validate)
with {:ok, settings} <- AppSettings.update(socket.assigns.settings, params) do
Livebook.Session.set_app_settings(socket.assigns.session.pid, settings)
end
{:noreply, assign(socket, changeset: changeset)}
end
def handle_event("deploy", %{"app_settings" => params}, socket) do
case AppSettings.update(socket.assigns.settings, params) do
{:ok, settings} ->
Livebook.Session.set_app_settings(socket.assigns.session.pid, settings)
if slug_taken?(settings.slug, socket.assigns.deployed_app_slug) do
{:noreply, assign(socket, deploy_confirmation: true)}
else
Livebook.Session.deploy_app(socket.assigns.session.pid)
{:noreply, socket}
end
{:error, _changeset} ->
{:noreply, socket}
end
end
def handle_event("deploy_confirmation_confirm", %{}, socket) do
Livebook.Session.deploy_app(socket.assigns.session.pid)
{:noreply, assign(socket, deploy_confirmation: false)}
end
def handle_event("deploy_confirmation_cancel", %{}, socket) do
{:noreply, assign(socket, deploy_confirmation: false)}
end
def handle_event("terminate_app", %{}, socket) do
{:noreply, confirm_app_termination(socket, socket.assigns.app.pid)}
end
@ -343,8 +186,4 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do
Livebook.Session.app_deactivate(app_session.pid)
{:noreply, socket}
end
defp slug_taken?(slug, deployed_app_slug) do
slug != deployed_app_slug and Livebook.Apps.exists?(slug)
end
end

View file

@ -0,0 +1,158 @@
defmodule LivebookWeb.SessionLive.AppSettingsComponent do
use LivebookWeb, :live_component
alias Livebook.Notebook.AppSettings
@impl true
def update(assigns, socket) do
changeset =
case socket.assigns do
%{changeset: changeset} when changeset.data == assigns.settings -> changeset
_ -> AppSettings.change(assigns.settings)
end
{:ok,
socket
|> assign(assigns)
|> assign(changeset: changeset)}
end
@impl true
def render(assigns) do
~H"""
<div class="p-6 max-w-4xl flex flex-col space-y-8">
<h3 class="text-2xl font-semibold text-gray-800">
App settings
</h3>
<.form :let={f} for={@changeset} phx-change="validate" phx-target={@myself} autocomplete="off">
<div class="flex flex-col space-y-4">
<.text_field field={f[:slug]} label="Slug" spellcheck="false" phx-debounce />
<div class="flex flex-col space-y-1">
<.checkbox_field
field={f[:access_type]}
label="Password-protected"
checked_value="protected"
unchecked_value="public"
/>
<%= if Ecto.Changeset.get_field(@changeset, :access_type) == :protected do %>
<.password_field field={f[:password]} spellcheck="false" phx-debounce />
<% end %>
</div>
<.radio_button_group_field
field={f[:multi_session]}
options={[{"false", "Single-session"}, {"true", "Multi-session"}]}
label="Session type"
help={
~S'''
Whether all users should share the
same session or be able to manually
start multiple sessions.
'''
}
full_width
/>
<.select_field
field={f[:auto_shutdown_ms]}
label="Shutdown after inactivity"
options={[
{"Never", ""},
{"5 seconds", "5000"},
{"1 minute", "60000"},
{"1 hour", "3600000"}
]}
help={
~S'''
Shuts down the session if it has no
users for the specified amount of time.
'''
}
/>
<.checkbox_field
field={f[:show_source]}
label="Show source"
help={
~S'''
When enabled, it makes notebook source
accessible in the app menu.
'''
}
/>
<.checkbox_field
field={f[:output_type]}
label="Only render rich outputs"
checked_value="rich"
unchecked_value="all"
help={
~S'''
When enabled, hides simple outputs
and only shows rich elements, such
as inputs, frames, tables, etc.
'''
}
/>
<%= if Ecto.Changeset.get_field(@changeset, :multi_session) do %>
<.checkbox_field
field={f[:show_existing_sessions]}
label="List existing sessions"
help={
~S'''
When enabled, the user can see all
currently running sessions and join
them before creating a new session.
'''
}
/>
<% else %>
<.checkbox_field
field={f[:zero_downtime]}
label="Zero-downtime deployment"
help={
~S'''
When enabled, a new version only becomes
available after it executes all of its
cells, making sure it is ready to use.
If an error happens, deploy is aborted.
'''
}
/>
<% end %>
</div>
<div class="mt-8 flex space-x-2">
<button
class="button-base button-blue"
type="button"
phx-click={JS.patch(~p"/sessions/#{@session.id}") |> JS.push("deploy_app")}
disabled={not @changeset.valid?}
>
<.remix_icon icon="rocket-line" class="align-middle mr-1" />
<span>Deploy</span>
</button>
<button class="button-base button-outlined-gray" type="reset" name="reset">
Reset
</button>
</div>
</.form>
</div>
"""
end
@impl true
def handle_event("validate", %{"_target" => ["reset"]}, socket) do
settings = AppSettings.new()
Livebook.Session.set_app_settings(socket.assigns.session.pid, settings)
{:noreply, assign(socket, changeset: AppSettings.change(settings))}
end
def handle_event("validate", %{"app_settings" => params}, socket) do
changeset =
socket.assigns.settings
|> AppSettings.change(params)
|> Map.put(:action, :validate)
with {:ok, settings} <- AppSettings.update(socket.assigns.settings, params) do
Livebook.Session.set_app_settings(socket.assigns.session.pid, settings)
end
{:noreply, assign(socket, changeset: changeset)}
end
end

View file

@ -87,6 +87,7 @@ defmodule LivebookWeb.Router do
live "/sessions/:id/secrets", SessionLive, :secrets
live "/sessions/:id/settings/runtime", SessionLive, :runtime_settings
live "/sessions/:id/settings/file", SessionLive, :file_settings
live "/sessions/:id/settings/app", SessionLive, :app_settings
live "/sessions/:id/bin", SessionLive, :bin
get "/sessions/:id/export/download/:format", SessionController, :download_source
live "/sessions/:id/export/:tab", SessionLive, :export

View file

@ -1409,8 +1409,16 @@ defmodule LivebookWeb.SessionLiveTest do
Livebook.Apps.subscribe()
view
|> element(~s/[data-el-app-info] form/)
|> render_submit(%{"app_settings" => %{"slug" => slug}})
|> element(~s/[data-el-app-info] a/, "Configure")
|> render_click()
view
|> element(~s/#app-settings-modal form/)
|> render_change(%{"app_settings" => %{"slug" => slug}})
view
|> element(~s/#app-settings-modal button/, "Deploy")
|> render_click()
assert_receive {:app_created, %{slug: ^slug} = app}