Support adding file entry via upload (#2085)

This commit is contained in:
Jonatan Kłosko 2023-07-18 21:31:25 +02:00 committed by GitHub
parent 29ebcda826
commit e95efd008f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 305 additions and 70 deletions

View file

@ -558,4 +558,84 @@ defmodule LivebookWeb.FormComponents do
</div>
"""
end
@doc """
Renders a drag-and-drop area for the given upload.
Once a file is selected, renders the entry.
## Examples
<.file_drop_input
upload={@uploads.file}
label="File"
on_clear={JS.push("clear_file", target: @myself)}
/>
"""
attr :upload, Phoenix.LiveView.UploadConfig, required: true
attr :label, :string, required: true
attr :on_clear, Phoenix.LiveView.JS, required: true
def file_drop_input(%{upload: %{entries: []}} = assigns) do
~H"""
<div>
<.live_file_input upload={@upload} class="hidden" />
<label
class="flex flex-col justify-center items-center w-full rounded-xl border-2 border-dashed border-gray-400 h-48 cursor-pointer"
phx-hook="Dropzone"
id={"#{@upload.ref}-dropzone"}
for={@upload.ref}
phx-drop-target={@upload.ref}
>
<span name="placeholder" class="font-medium text-gray-400">
Click to select a file or drag a local file here
</span>
</label>
</div>
"""
end
def file_drop_input(assigns) do
~H"""
<div>
<.live_file_input upload={@upload} class="hidden" />
<.label><%= @label %></.label>
<div :for={entry <- @upload.entries} class="flex flex-col gap-1">
<div class="flex flex-col gap-0.5">
<div class="flex items-center justify-between text-gray-700">
<span><%= entry.client_name %></span>
<button
type="button"
class="ml-1 text-gray-500 hover:text-gray-900"
phx-click={@on_clear}
tabindex="-1"
>
<.remix_icon icon="close-line" />
</button>
<span class="flex-grow"></span>
<span :if={entry.preflighted?} class="text-sm font-medium">
<%= entry.progress %>%
</span>
</div>
<div :if={entry.preflighted?} class="w-full h-2 rounded-lg bg-blue-200">
<div
class="h-full rounded-lg bg-blue-600 transition-all ease-out duration-1000"
style={"width: #{entry.progress}%"}
>
</div>
</div>
</div>
</div>
</div>
"""
end
@doc """
Checks if the given upload makes the form disabled.
"""
@spec upload_disabled?(Phoenix.LiveView.UploadConfig.t()) :: boolean()
def upload_disabled?(upload) do
upload.entries == [] or upload.errors != [] or Enum.any?(upload.entries, & &1.preflighted?)
end
end

View file

@ -16,40 +16,13 @@ defmodule LivebookWeb.OpenLive.UploadComponent do
<p class="text-gray-700" id="import-from-file">
Drag and drop a .livemd file below to import it.
</p>
<form
id="upload-file-form"
phx-submit="save"
phx-change="validate"
phx-drop-target={@uploads.notebook.ref}
phx-target={@myself}
class="flex flex-col items-start"
>
<.live_file_input
upload={@uploads.notebook}
class="hidden"
aria-labelledby="import-from-file"
/>
<div
class="flex flex-col justify-center items-center w-full rounded-xl border-2 border-dashed border-gray-400 h-48"
phx-hook="Dropzone"
id="import-file-upload-dropzone"
>
<%= if @uploads.notebook.entries == [] do %>
<span name="placeholder" class="font-medium text-gray-400">Drop your notebook here</span>
<% else %>
<div :for={file <- @uploads.notebook.entries} class="flex items-center">
<span class="font-medium text-gray-400"><%= file.client_name %></span>
<button
type="button"
class="icon-button"
phx-click="clear-file"
phx-target={@myself}
tabindex="-1"
>
<.remix_icon icon="close-line" class="text-xl text-gray-300 hover:text-gray-500" />
</button>
</div>
<% end %>
<form id="upload-file-form" phx-submit="save" phx-change="validate" phx-target={@myself}>
<div class="flex flex-col space-y-4">
<.file_drop_input
upload={@uploads.notebook}
label="Notebook"
on_clear={JS.push("clear_file", target: @myself)}
/>
</div>
<%= if @error do %>
<div class="text-red-500 text-sm py-2">
@ -59,7 +32,7 @@ defmodule LivebookWeb.OpenLive.UploadComponent do
<button
type="submit"
class="mt-5 button-base button-blue"
disabled={@uploads.notebook.entries == [] || @error}
disabled={@error or upload_disabled?(@uploads.notebook)}
>
Import
</button>
@ -68,12 +41,6 @@ defmodule LivebookWeb.OpenLive.UploadComponent do
"""
end
@impl true
def handle_event("clear-file", _params, socket) do
{socket, _entries} = Phoenix.LiveView.Upload.maybe_cancel_uploads(socket)
{:noreply, assign(socket, error: false)}
end
@impl true
def handle_event("validate", _params, socket) do
has_error? = Enum.any?(socket.assigns.uploads.notebook.entries, &(not &1.valid?))
@ -81,7 +48,11 @@ defmodule LivebookWeb.OpenLive.UploadComponent do
{:noreply, assign(socket, error: has_error?)}
end
@impl true
def handle_event("clear_file", _params, socket) do
{socket, _entries} = Phoenix.LiveView.Upload.maybe_cancel_uploads(socket)
{:noreply, assign(socket, error: false)}
end
def handle_event("save", _params, socket) do
consume_uploaded_entries(socket, :notebook, fn %{path: path}, _entry ->
content = File.read!(path)

View file

@ -860,6 +860,13 @@ defmodule LivebookWeb.SessionLive do
<.remix_icon icon="download-cloud-2-line" class="align-middle" />
<span class="font-medium">From URL</span>
</.link>
<.link
patch={~p"/sessions/#{@session.id}/add-file/upload"}
class={["tab", @tab == "upload" && "active"]}
>
<.remix_icon icon="file-upload-line" class="align-middle" />
<span class="font-medium">From upload</span>
</.link>
<.link
patch={~p"/sessions/#{@session.id}/add-file/unlisted"}
class={["tab", @tab == "unlisted" && "active"]}
@ -881,6 +888,12 @@ defmodule LivebookWeb.SessionLive do
id="add-file-entry-from-url"
session={@session}
/>
<.live_component
:if={@tab == "upload"}
module={LivebookWeb.SessionLive.AddFileEntryUploadComponent}
id="add-file-entry-from-upload"
session={@session}
/>
<.live_component
:if={@tab == "unlisted"}
module={LivebookWeb.SessionLive.AddFileEntryUnlistedComponent}

View file

@ -0,0 +1,119 @@
defmodule LivebookWeb.SessionLive.AddFileEntryUploadComponent do
use LivebookWeb, :live_component
import Ecto.Changeset
alias Livebook.FileSystem
@impl true
def mount(socket) do
{:ok,
socket
|> assign(changeset: changeset(), error_message: nil)
|> allow_upload(:file, accept: :any, max_entries: 1, max_file_size: 100_000_000_000)}
end
defp changeset(attrs \\ %{}) do
data = %{name: nil}
types = %{name: :string}
cast({data, types}, attrs, [:name])
|> validate_required([:name])
|> Livebook.Notebook.validate_file_entry_name(:name)
end
@impl true
def render(assigns) do
~H"""
<div>
<div :if={@error_message} class="mb-6 error-box">
<%= @error_message %>
</div>
<.form
:let={f}
for={@changeset}
as={:data}
id="add-file-entry-form"
phx-change="validate"
phx-submit="add"
phx-target={@myself}
>
<div class="flex flex-col space-y-4">
<.file_drop_input
upload={@uploads.file}
label="File"
on_clear={JS.push("clear_file", target: @myself)}
/>
<.text_field field={f[:name]} label="Name" autocomplete="off" phx-debounce="blur" />
</div>
<div class="mt-6 flex space-x-3">
<button
class="button-base button-blue"
type="submit"
disabled={not @changeset.valid? or upload_disabled?(@uploads.file)}
>
Add
</button>
<.link patch={~p"/sessions/#{@session.id}"} class="button-base button-outlined-gray">
Cancel
</.link>
</div>
</.form>
</div>
"""
end
@impl true
def handle_event("validate", %{"data" => data} = params, socket) do
upload_entries = socket.assigns.uploads.file.entries
data =
case {params["_target"], data["name"], upload_entries} do
{["file"], "", [entry]} ->
%{data | "name" => entry.client_name}
_ ->
data
end
changeset = data |> changeset() |> Map.replace!(:action, :validate)
{:noreply, assign(socket, changeset: changeset)}
end
def handle_event("clear_file", _params, socket) do
{socket, _entries} = Phoenix.LiveView.Upload.maybe_cancel_uploads(socket)
{:noreply, assign(socket, error_message: nil)}
end
def handle_event("add", %{"data" => data}, socket) do
data
|> changeset()
|> apply_action(:insert)
|> case do
{:ok, data} ->
%{files_dir: files_dir} = socket.assigns.session
[upload_result] =
consume_uploaded_entries(socket, :file, fn %{path: path}, _entry ->
upload_file = FileSystem.File.local(path)
destination_file = FileSystem.File.resolve(files_dir, data.name)
result = FileSystem.File.copy(upload_file, destination_file)
{:ok, result}
end)
case upload_result do
:ok ->
file_entry = %{name: data.name, type: :attachment}
Livebook.Session.add_file_entries(socket.assigns.session.pid, [file_entry])
{:noreply, push_patch(socket, to: ~p"/sessions/#{socket.assigns.session.id}")}
{:error, message} ->
{:noreply, assign(socket, error_message: message)}
end
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
end

View file

@ -39,18 +39,8 @@ defmodule LivebookWeb.SessionLive.InsertImageComponent do
<div :if={@error_message} class="error-box">
<%= @error_message %>
</div>
<div :for={entry <- @uploads.image.entries} class="flex flex-col space-y-1">
<div class="flex justify-between text-gray-700">
<span><%= entry.client_name %></span>
<span :if={entry.preflighted?}><%= entry.progress %>%</span>
</div>
<div :if={entry.preflighted?} class="w-full h-2 rounded-lg bg-blue-200">
<div
class="h-full rounded-lg bg-blue-600 transition-all ease-out duration-1000"
style={"width: #{entry.progress}%"}
>
</div>
</div>
<div :for={entry <- @uploads.image.entries}>
<.live_img_preview entry={entry} class="max-h-80 m-auto" />
</div>
<.form
:let={f}
@ -60,18 +50,13 @@ defmodule LivebookWeb.SessionLive.InsertImageComponent do
phx-submit="save"
phx-target={@myself}
>
<div class="w-full flex space-x-2">
<div>
<label>
<.live_file_input upload={@uploads.image} class="hidden" />
<div class="cursor-pointer button-base button-gray button-square-icon">
<.remix_icon icon="folder-upload-line" />
</div>
</label>
</div>
<div class="grow">
<.text_field field={f[:name]} placeholder="Name" autocomplete="off" phx-debounce="blur" />
</div>
<div class="flex flex-col space-y-4">
<.file_drop_input
upload={@uploads.image}
label="File"
on_clear={JS.push("clear_file", target: @myself)}
/>
<.text_field field={f[:name]} label="Name" autocomplete="off" phx-debounce="blur" />
</div>
<div class="mt-8 flex justify-end space-x-2">
<.link patch={@return_to} class="button-base button-outlined-gray">
@ -80,10 +65,7 @@ defmodule LivebookWeb.SessionLive.InsertImageComponent do
<button
class="button-base button-blue"
type="submit"
disabled={
not @changeset.valid? or @uploads.image.entries == [] or
@uploads.image.errors != []
}
disabled={not @changeset.valid? or upload_disabled?(@uploads.image)}
>
Upload
</button>
@ -111,6 +93,11 @@ defmodule LivebookWeb.SessionLive.InsertImageComponent do
{:noreply, assign(socket, changeset: changeset)}
end
def handle_event("clear_file", _params, socket) do
{socket, _entries} = Phoenix.LiveView.Upload.maybe_cancel_uploads(socket)
{:noreply, assign(socket, error_message: nil)}
end
def handle_event("save", %{"data" => data}, socket) do
data
|> changeset()

View file

@ -195,6 +195,35 @@ defmodule LivebookWeb.OpenLiveTest do
close_session_by_path(path)
end
test "allows importing notebook from upload", %{conn: conn} do
{:ok, view, _} = live(conn, ~p"/open/upload")
view
|> file_input("#upload-file-form", :notebook, [
%{
last_modified: 1_594_171_879_000,
name: "notebook.livemd",
content: """
# My notebook
""",
size: 14,
type: "text/plain"
}
])
|> render_upload("notebook.livemd")
view
|> element("#upload-file-form")
|> render_submit()
{path, _flash} = assert_redirect(view, 5000)
{:ok, view, _} = live(conn, path)
assert render(view) =~ "My notebook"
close_session_by_path(path)
end
end
describe "public import endpoint" do

View file

@ -1667,6 +1667,42 @@ defmodule LivebookWeb.SessionLiveTest do
Session.get_data(session.pid)
end
test "adding :attachment file entry from upload", %{conn: conn, session: session} do
Session.subscribe(session.id)
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}/add-file/upload")
view
|> file_input(~s{#add-file-entry-form}, :file, [
%{
last_modified: 1_594_171_879_000,
name: "image.jpg",
content: "content",
size: 7,
type: "text/plain"
}
])
|> render_upload("image.jpg")
# Validations
assert view
|> element(~s{#add-file-entry-form})
|> render_change(%{"data" => %{"name" => "na me"}}) =~
"should contain only alphanumeric characters, dash, underscore and dot"
view
|> element(~s{#add-file-entry-form})
|> render_submit(%{"data" => %{"name" => "image.jpg"}})
assert_receive {:operation, {:add_file_entries, _client_id, [%{name: "image.jpg"}]}}
assert %{notebook: %{file_entries: [%{type: :attachment, name: "image.jpg"}]}} =
Session.get_data(session.pid)
assert FileSystem.File.resolve(session.files_dir, "image.jpg") |> FileSystem.File.read() ==
{:ok, "content"}
end
test "adding :attachment file entry from unlisted files", %{conn: conn, session: session} do
for name <- ["file1.txt", "file2.txt", "file3.txt"] do
file = FileSystem.File.resolve(session.files_dir, name)