mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-08 20:46:16 +08:00
Support adding file entry via upload (#2085)
This commit is contained in:
parent
29ebcda826
commit
e95efd008f
7 changed files with 305 additions and 70 deletions
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Reference in a new issue