diff --git a/lib/livebook_web/components/form_components.ex b/lib/livebook_web/components/form_components.ex index 313ca722e..82b450e44 100644 --- a/lib/livebook_web/components/form_components.ex +++ b/lib/livebook_web/components/form_components.ex @@ -558,4 +558,84 @@ defmodule LivebookWeb.FormComponents do """ 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""" +
+ <.live_file_input upload={@upload} class="hidden" /> + +
+ """ + end + + def file_drop_input(assigns) do + ~H""" +
+ <.live_file_input upload={@upload} class="hidden" /> + <.label><%= @label %> +
+
+
+ <%= entry.client_name %> + + + + <%= entry.progress %>% + +
+
+
+
+
+
+
+
+ """ + 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 diff --git a/lib/livebook_web/live/open_live/upload_component.ex b/lib/livebook_web/live/open_live/upload_component.ex index 87f8d33c2..4fa5a582f 100644 --- a/lib/livebook_web/live/open_live/upload_component.ex +++ b/lib/livebook_web/live/open_live/upload_component.ex @@ -16,40 +16,13 @@ defmodule LivebookWeb.OpenLive.UploadComponent do

Drag and drop a .livemd file below to import it.

-
- <.live_file_input - upload={@uploads.notebook} - class="hidden" - aria-labelledby="import-from-file" - /> -
- <%= if @uploads.notebook.entries == [] do %> - Drop your notebook here - <% else %> -
- <%= file.client_name %> - -
- <% end %> + +
+ <.file_drop_input + upload={@uploads.notebook} + label="Notebook" + on_clear={JS.push("clear_file", target: @myself)} + />
<%= if @error do %>
@@ -59,7 +32,7 @@ defmodule LivebookWeb.OpenLive.UploadComponent do @@ -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) diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index be22842b5..b38ab013c 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -860,6 +860,13 @@ defmodule LivebookWeb.SessionLive do <.remix_icon icon="download-cloud-2-line" class="align-middle" /> From URL + <.link + patch={~p"/sessions/#{@session.id}/add-file/upload"} + class={["tab", @tab == "upload" && "active"]} + > + <.remix_icon icon="file-upload-line" class="align-middle" /> + From upload + <.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} diff --git a/lib/livebook_web/live/session_live/add_file_entry_upload_component.ex b/lib/livebook_web/live/session_live/add_file_entry_upload_component.ex new file mode 100644 index 000000000..94cba847b --- /dev/null +++ b/lib/livebook_web/live/session_live/add_file_entry_upload_component.ex @@ -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""" +
+
+ <%= @error_message %> +
+ <.form + :let={f} + for={@changeset} + as={:data} + id="add-file-entry-form" + phx-change="validate" + phx-submit="add" + phx-target={@myself} + > +
+ <.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" /> +
+
+ + <.link patch={~p"/sessions/#{@session.id}"} class="button-base button-outlined-gray"> + Cancel + +
+ +
+ """ + 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 diff --git a/lib/livebook_web/live/session_live/insert_image_component.ex b/lib/livebook_web/live/session_live/insert_image_component.ex index 5a72f3774..eaa82caf8 100644 --- a/lib/livebook_web/live/session_live/insert_image_component.ex +++ b/lib/livebook_web/live/session_live/insert_image_component.ex @@ -39,18 +39,8 @@ defmodule LivebookWeb.SessionLive.InsertImageComponent do
<%= @error_message %>
-
-
- <%= entry.client_name %> - <%= entry.progress %>% -
-
-
-
-
+
+ <.live_img_preview entry={entry} class="max-h-80 m-auto" />
<.form :let={f} @@ -60,18 +50,13 @@ defmodule LivebookWeb.SessionLive.InsertImageComponent do phx-submit="save" phx-target={@myself} > -
-
- -
-
- <.text_field field={f[:name]} placeholder="Name" autocomplete="off" phx-debounce="blur" /> -
+
+ <.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" />
<.link patch={@return_to} class="button-base button-outlined-gray"> @@ -80,10 +65,7 @@ defmodule LivebookWeb.SessionLive.InsertImageComponent do @@ -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() diff --git a/test/livebook_web/live/open_live_test.exs b/test/livebook_web/live/open_live_test.exs index 6c58fb7bc..3d0480ee2 100644 --- a/test/livebook_web/live/open_live_test.exs +++ b/test/livebook_web/live/open_live_test.exs @@ -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 diff --git a/test/livebook_web/live/session_live_test.exs b/test/livebook_web/live/session_live_test.exs index e598c1769..ee6c635ae 100644 --- a/test/livebook_web/live/session_live_test.exs +++ b/test/livebook_web/live/session_live_test.exs @@ -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)