Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

track in-progress preflights (fixes #2965) #3004

Merged
merged 2 commits into from
Jan 16, 2024

Conversation

SteffenDE
Copy link
Collaborator

Fixes #2965

Application.put_env(:sample, Example.Endpoint,
  http: [ip: {127, 0, 0, 1}, port: 5001],
  server: true,
  live_view: [signing_salt: "aaaaaaaa"],
  secret_key_base: String.duplicate("a", 64),
  pubsub_server: Example.PubSub
)

Mix.install([
  {:plug_cowboy, "~> 2.5"},
  {:jason, "~> 1.2"},
  {:phoenix, "~> 1.7.7"},
  {:phoenix_live_view, github: "phoenixframework/phoenix_live_view", branch: "main", override: true}
])

defmodule Example.ErrorView do
  def render(template, _), do: Phoenix.Controller.status_message_from_template(template)
end

defmodule Example.NoOpWriter do
  @behaviour Phoenix.LiveView.UploadWriter

  @impl true
  def init(opts) do
    {:ok, %{parts: [], part_number: 1}}
  end

  @impl true
  def meta(state), do: state

  @impl true
  def write_chunk(data, state) do
    %{part_number: part_number} = state
    part = "foo"
    {:ok, %{state | parts: [part | state.parts], part_number: part_number + 1}}
  end

  def close(state, :cancel) do
    {:ok, :aborted}
  end

  @impl true
  def close(state, :done) do
    {:ok, %{}}
  end
end

defmodule Example.CoreComponents do
  use Phoenix.Component
  attr(:for, :any, required: true, doc: "the datastructure for the form")
  attr(:as, :any, default: nil, doc: "the server side parameter to collect all input under")

  attr(:rest, :global,
    include: ~w(autocomplete name rel action enctype method novalidate target multipart),
    doc: "the arbitrary HTML attributes to apply to the form tag"
  )

  slot(:inner_block, required: true)
  slot(:actions, doc: "the slot for form actions, such as a submit button")

  def simple_form(assigns) do
    ~H"""
    <.form :let={f} for={@for} as={@as} {@rest}>
      <div>
        <%= render_slot(@inner_block, f) %>
        <div :for={action <- @actions}>
          <%= render_slot(action, f) %>
        </div>
      </div>
    </.form>
    """
  end
end

defmodule Example.UploadLive do
  use Phoenix.LiveView, layout: {__MODULE__, :live}

  import Example.CoreComponents

  def mount(_params, _session, socket) do
    socket =
      socket
      |> allow_upload(:files,
        accept: :any,
        max_entries: 1500,
        # minimum 5 mb for multipart
        chunk_size: 5 * 1_024 * 1_024,
        max_file_size: 10_000_000_000,
        auto_upload: true,
        writer: &r2_writer/3,
        progress: &handle_progress/3
      )
      |> assign(:form, to_form(%{}))

    {:ok, socket}
  end

  defp phx_vsn, do: Application.spec(:phoenix, :vsn)
  defp lv_vsn, do: Application.spec(:phoenix_live_view, :vsn)

  def render("live.html", assigns) do
    ~H"""
    <script src={"https://cdn.jsdelivr.net/npm/phoenix@#{phx_vsn()}/priv/static/phoenix.min.js"}></script>
    <script src="https://cdn.jsdelivr.net/gh/SteffenDE/phoenix_live_view@issue_2965_assets/priv/static/phoenix_live_view.js"></script>
    <script>
      const QueuedUploaderHook = {
        async mounted() {
          const maxConcurrency = this.el.dataset.maxConcurrency || 3;
          let filesRemaining = [];

          this.el.addEventListener("input", async (event) => {
            event.preventDefault()

            if (event.target instanceof HTMLInputElement) {
              const files_html = event.target.files;
              if (files_html) {

                const rawFiles = Array.from(files_html);
                console.log("raw files", rawFiles);
                const fileNames = rawFiles.map((f) => {
                  return f.name;
                });

                this.pushEvent("upload_scrub_list", { file_names: fileNames }, ({ deduped_filenames }, ref) => {
                  console.log("deduped filenames", deduped_filenames);
                  const files = rawFiles.filter((f) => {
                    return deduped_filenames.includes(f.name);
                  });
                  console.log("scrubbed files", files);
                  filesRemaining = files;
                  const firstFiles = files.slice(0, maxConcurrency);
                  console.log("firstFiles", { firstFiles });
                  this.upload("files", firstFiles);

                  filesRemaining.splice(0, maxConcurrency);
                });

              }
            }
          });

          this.handleEvent("upload_send_next_file", () => {
            // console.log("Uploading more files! Remainder:", filesRemaining);

            if (filesRemaining.length > 0) {
              const nextFile = filesRemaining.shift();
              if (nextFile != undefined) {
                console.log("Uploading file: ", nextFile.name);
                this.upload("files", [nextFile]);
              }
            } else {
              console.log("Done uploading, noop!");
            }
          });
        }
      };
      let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket, {hooks: {QueuedUploaderHook}});
      liveSocket.connect();
    </script>

    <%= @inner_content %>
    """
  end

  def render(assigns) do
    ~H"""
    <main>
      <h1>Uploader reproduction</h1>
      <.simple_form for={@form} phx-submit="save" phx-change="validate">
        <%!-- use phx-drop-target with the upload ref to enable file drag and drop --%>
        <%!-- phx-drop-target={@uploads.files.ref} --%>
        <section>
          <.live_file_input upload={@uploads.files} style="display: none;" />
          <input
            id="fileinput"
            type="file"
            multiple
            phx-hook="QueuedUploaderHook"
            disabled={file_picker_disabled?(@uploads)}
          />
          <h2 :if={length(@uploads.files.entries) > 0}>Currently uploading files</h2>
          <div>
            <table>
              <!-- head -->
              <thead>
                <tr>
                  <th>File Name</th>
                  <th>Progress</th>
                  <th>Cancel</th>
                  <th>Errors</th>
                </tr>
              </thead>
              <tbody>
                <%= for entry <- uploads_in_progress(@uploads) do %>
                  <tr>
                    <td><%= entry.client_name %></td>
                    <td>
                      <progress value={entry.progress} max="100">
                        <%= entry.progress %>%
                      </progress>
                    </td>

                    <td>
                      <%!-- <button
                        type="button"
                        phx-click="retry-upload"
                        phx-value-ref={entry.ref}
                        aria-label="cancel"
                      >
                        <i class="fa-solid fa-arrow-rotate-right"></i>
                      </button> --%>
                      <button
                        type="button"
                        phx-click="cancel-upload"
                        phx-value-ref={entry.ref}
                        aria-label="cancel"
                      >
                        <span>&times;</span>
                      </button>
                    </td>
                    <td>
                      <%= for err <- upload_errors(@uploads.files, entry) do %>
                        <p style="color: red;"><%= error_to_string(err) %></p>
                      <% end %>
                    </td>
                  </tr>
                <% end %>
              </tbody>
            </table>
          </div>
          <%!-- Phoenix.Component.upload_errors/1 returns a list of error atoms --%>
          <%= for err <- upload_errors(@uploads.files) do %>
            <p style="text-red"><%= error_to_string(err) %></p>
          <% end %>
        </section>
      </.simple_form>
    </main>
    """
  end

  def handle_progress(
        :files,
        entry,
        %{
          assigns: %{
            uploads: %{files: %{entries: entries}}
          }
        } =
          socket
      ) do

    if entry.done? do
      {:noreply, push_event(socket, "upload_send_next_file", %{})}
    else
      {:noreply, socket}
    end
  end

  # This dedupes against s3, just doing a no-op here to preserve the original uploader js code
  def handle_event(
        "upload_scrub_list",
        %{"file_names" => file_names},
        socket
      ) do
    {:reply, %{deduped_filenames: file_names}, socket}
  end

  def handle_event("validate", _params, socket) do
    {:noreply, socket}
  end

  def handle_event("cancel-upload", %{"ref" => ref}, socket) do
    file = Enum.find(socket.assigns.uploads.files.entries, fn f -> f.ref == ref end)

    {:noreply, cancel_upload(socket, :files, ref)}
  end

  def handle_event("save", _params, socket) do
    # consume_uploaded_entries(socket, :files, fn _info, _entry ->
    #   {:ok, %{}}
    # end)

    {:noreply, socket}
  end

  def error_to_string(:too_large), do: "Too large"
  def error_to_string(:not_accepted), do: "You have selected an unacceptable file type"
  def error_to_string(:s3_error), do: "Error on writing to cloudflare"

  def error_to_string(unknown) do
    IO.inspect(unknown, label: "Unknown error")
    "unknown error"
  end

  ## Helpers

  defp submit_disabled?(uploads, staged_files) do
    cond do
      Enum.any?(uploads.files.entries, fn entry -> entry.done? == false end) ->
        true

      length(uploads.files.entries) + length(staged_files) < 1 ->
        true

      true ->
        false
    end
  end

  defp file_picker_disabled?(uploads) do
    Enum.any?(uploads.files.entries, fn e -> !e.done? end)
  end

  defp r2_writer(_name, %Phoenix.LiveView.UploadEntry{} = entry, socket) do
    {
      Example.NoOpWriter,
      provider: :r2, name: entry.client_name
    }
  end

  defp progress(total, todo) do
    completed = total - todo
    (completed / total) |> Kernel.*(100) |> trunc()
  end

  defp uploads_in_progress(uploads) do
    uploads.files.entries
  end
end

defmodule Example.Router do
  use Phoenix.Router
  import Phoenix.LiveView.Router

  pipeline :browser do
    plug(:accepts, ["html"])
  end

  scope "/", Example do
    pipe_through(:browser)

    live("/", UploadLive, :index)
  end
end

defmodule Example.Endpoint do
  use Phoenix.Endpoint, otp_app: :sample
  socket("/live", Phoenix.LiveView.Socket)
  plug(Example.Router)
end

{:ok, _} =
  Supervisor.start_link(
    [Example.Endpoint, {Phoenix.PubSub, [name: Example.PubSub, adapter: Phoenix.PubSub.PG2]}],
    strategy: :one_for_one
  )

Process.sleep(:infinity)

See the issue for demo files to upload.

@chrismccord chrismccord merged commit 43f4224 into phoenixframework:main Jan 16, 2024
4 checks passed
@chrismccord
Copy link
Member

❤️❤️❤️🐥🔥

chrismccord added a commit that referenced this pull request Jan 17, 2024
* track in-progress preflights (fixes #2965)

* Update assets/js/phoenix_live_view/upload_entry.js

---------

Co-authored-by: Chris McCord <chris@chrismccord.com>
SteffenDE added a commit to SteffenDE/phoenix_live_view that referenced this pull request Feb 22, 2024
SteffenDE added a commit to SteffenDE/phoenix_live_view that referenced this pull request Feb 22, 2024
chrismccord pushed a commit that referenced this pull request Feb 28, 2024
* Properly track preflighted uploads on the server

Fixes #3115.
Relates to #3004.
Partially fixes #2835.

* add test for #2965, #3115
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
2 participants