diff --git a/lib/microwaveprop_web/live/import_live.ex b/lib/microwaveprop_web/live/import_live.ex new file mode 100644 index 00000000..7a66d84f --- /dev/null +++ b/lib/microwaveprop_web/live/import_live.ex @@ -0,0 +1,222 @@ +defmodule MicrowavepropWeb.ImportLive do + @moduledoc """ + Shows live progress for an async CSV/ADIF contact import. + + Mounts on `/imports/:id`, loads the `ImportRun`, and subscribes to the + `"csv_import:"` PubSub topic so counters update as + `ContactImportWorker` chunks finish. + """ + use MicrowavepropWeb, :live_view + + alias Microwaveprop.Radio.ImportRun + alias Microwaveprop.Repo + + @pubsub Microwaveprop.PubSub + + @impl true + def mount(%{"id" => id}, _session, socket) do + case load_run(id) do + nil -> + {:ok, + socket + |> put_flash(:error, "Import not found.") + |> push_navigate(to: ~p"/submit")} + + %ImportRun{} = run -> + if connected?(socket) do + Phoenix.PubSub.subscribe(@pubsub, "csv_import:#{run.id}") + end + + {:ok, + socket + |> assign(:page_title, "Import ##{String.slice(run.id, 0, 8)}") + |> assign(:run, run)} + end + end + + @impl true + def handle_info({:import_progress, _id, %ImportRun{} = updated}, socket) do + {:noreply, assign(socket, :run, updated)} + end + + defp load_run(id) do + if valid_uuid?(id), do: Repo.get(ImportRun, id) + end + + defp valid_uuid?(id) when is_binary(id) do + case Ecto.UUID.cast(id) do + {:ok, _} -> true + :error -> false + end + end + + defp valid_uuid?(_), do: false + + @impl true + def render(assigns) do + ~H""" + + <.header> + Contact Import + <:subtitle> + Submitted by {@run.submitter_email} + · {@run.total_rows} {pluralize(@run.total_rows, "row")} + + <:actions> + + {status_label(@run.status)} + + + + +
+
+
+ + {@run.processed_rows} / {@run.total_rows} processed + + {progress_percent(@run)}% +
+ + +
+ +
+
+
Imported
+
+ {@run.imported_count} +
+
New contacts inserted
+
+
+
Refined
+
+ {@run.refined_count} +
+
Existing contacts updated
+
+
+
Errors
+
+ {@run.error_count} +
+
Rows that failed at insert
+
+
+ +
+ <.icon name="hero-check-circle" class="w-6 h-6" /> +
+
Import complete
+
+ Enrichment (weather, terrain, propagation) will continue in the background. +
+
+
+ +
+ <.icon name="hero-exclamation-triangle" class="w-6 h-6" /> + Import failed before processing all rows. +
+ +
0} + id="import-errors-section" + class="rounded-box border border-base-300" + > + + {@run.error_count} {pluralize(@run.error_count, "row")} with errors + +
+ + + + + + + + + + + + + +
RowErrors
Row {row_num} +
{msg}
+
+
+
+ +
+ <.link navigate={~p"/submit"} class="btn btn-outline"> + <.icon name="hero-arrow-left" class="w-4 h-4" /> Upload another + + <.link + :if={@run.status == "complete"} + navigate={~p"/contacts"} + class="btn btn-primary" + > + View contacts <.icon name="hero-arrow-right" class="w-4 h-4" /> + +
+
+
+ """ + end + + defp status_label("pending"), do: "Pending" + defp status_label("running"), do: "Running" + defp status_label("complete"), do: "Complete" + defp status_label("failed"), do: "Failed" + defp status_label(other), do: to_string(other) + + defp status_badge_class("pending"), do: "badge-neutral" + defp status_badge_class("running"), do: "badge-info" + defp status_badge_class("complete"), do: "badge-success" + defp status_badge_class("failed"), do: "badge-error" + defp status_badge_class(_), do: "badge-neutral" + + defp progress_class("complete"), do: "progress-success" + defp progress_class("failed"), do: "progress-error" + defp progress_class("running"), do: "progress-info" + defp progress_class(_), do: "progress-primary" + + defp progress_percent(%ImportRun{total_rows: 0}), do: 0 + + defp progress_percent(%ImportRun{total_rows: total, processed_rows: processed}) do + min(100, round(processed * 100 / total)) + end + + defp pluralize(1, word), do: word + defp pluralize(_, word), do: word <> "s" + + # `errors` is a JSONB map of "row_num" (string) → [messages]. Sort by + # numeric row number for display; fall back to string order if a key + # can't be parsed as an integer. + defp sorted_errors(nil), do: [] + + defp sorted_errors(errors) when is_map(errors) do + errors + |> Enum.map(fn {row_num, messages} -> {row_num, List.wrap(messages)} end) + |> Enum.sort_by(fn {row_num, _} -> + case Integer.parse(to_string(row_num)) do + {n, _} -> n + :error -> :infinity + end + end) + end +end diff --git a/lib/microwaveprop_web/live/submit_live.ex b/lib/microwaveprop_web/live/submit_live.ex index a76c80f3..d4b59d99 100644 --- a/lib/microwaveprop_web/live/submit_live.ex +++ b/lib/microwaveprop_web/live/submit_live.ex @@ -18,8 +18,18 @@ defmodule MicrowavepropWeb.SubmitLive do def mount(_params, _session, socket) do socket = socket - |> allow_upload(:csv, accept: ~w(.csv), max_entries: 1, max_file_size: 10_000_000) - |> allow_upload(:adif, accept: :any, max_entries: 1, max_file_size: 10_000_000) + |> allow_upload(:csv, + accept: ~w(.csv), + max_entries: 1, + max_file_size: 10_000_000, + auto_upload: false + ) + |> allow_upload(:adif, + accept: :any, + max_entries: 1, + max_file_size: 10_000_000, + auto_upload: false + ) changeset = Radio.change_contact(%Contact{}) @@ -32,7 +42,6 @@ defmodule MicrowavepropWeb.SubmitLive do submitted_at: nil, active_tab: :single, csv_preview: nil, - csv_result: nil, csv_email: "" ) @@ -163,15 +172,14 @@ defmodule MicrowavepropWeb.SubmitLive do def handle_event("confirm_csv", _params, socket) do case socket.assigns.csv_preview do - %{valid: valid_rows, refinements: refinements} + %{valid: valid_rows, refinements: refinements} = preview when valid_rows != [] or refinements != [] -> - {:ok, commit_result} = - CsvImport.commit(%{valid: valid_rows, refinements: refinements}) + {:ok, run_id} = CsvImport.enqueue(preview, socket.assigns.csv_email) {:noreply, socket - |> assign(csv_preview: nil, csv_result: commit_result) - |> put_flash(:info, commit_flash(commit_result))} + |> assign(csv_preview: nil) + |> push_navigate(to: ~p"/imports/#{run_id}")} _ -> {:noreply, put_flash(socket, :error, "Nothing to import.")} @@ -179,17 +187,13 @@ defmodule MicrowavepropWeb.SubmitLive do end def handle_event("cancel_csv", _params, socket) do - {:noreply, assign(socket, csv_preview: nil, csv_result: nil)} - end - - def handle_event("reset_csv", _params, socket) do - {:noreply, assign(socket, csv_preview: nil, csv_result: nil)} + {:noreply, assign(socket, csv_preview: nil)} end defp handle_adif_preview(content, email, socket) do case AdifImport.preview(content, email) do {:ok, preview} -> - {:noreply, assign(socket, csv_preview: preview, csv_result: nil, csv_email: email)} + {:noreply, assign(socket, csv_preview: preview, csv_email: email)} {:error, :no_records} -> {:noreply, put_flash(socket, :error, "ADIF file contains no records")} @@ -199,7 +203,7 @@ defmodule MicrowavepropWeb.SubmitLive do defp handle_csv_preview(content, email, socket) do case CsvImport.preview(content, email) do {:ok, preview} -> - {:noreply, assign(socket, csv_preview: preview, csv_result: nil, csv_email: email)} + {:noreply, assign(socket, csv_preview: preview, csv_email: email)} {:error, :empty_csv} -> {:noreply, put_flash(socket, :error, "CSV file is empty")} @@ -297,22 +301,16 @@ defmodule MicrowavepropWeb.SubmitLive do current_user={current_user(assigns)} /> <% :csv -> %> - <%= cond do %> - <% @csv_result -> %> - <.csv_results result={@csv_result} /> - <% @csv_preview -> %> - <.csv_preview preview={@csv_preview} /> - <% true -> %> - <.csv_upload_form uploads={@uploads} current_user={current_user(assigns)} /> + <%= if @csv_preview do %> + <.csv_preview preview={@csv_preview} /> + <% else %> + <.csv_upload_form uploads={@uploads} current_user={current_user(assigns)} /> <% end %> <% :adif -> %> - <%= cond do %> - <% @csv_result -> %> - <.csv_results result={@csv_result} /> - <% @csv_preview -> %> - <.csv_preview preview={@csv_preview} /> - <% true -> %> - <.adif_upload_form uploads={@uploads} current_user={current_user(assigns)} /> + <%= if @csv_preview do %> + <.csv_preview preview={@csv_preview} /> + <% else %> + <.adif_upload_form uploads={@uploads} current_user={current_user(assigns)} /> <% end %> <% end %> @@ -442,7 +440,7 @@ defmodule MicrowavepropWeb.SubmitLive do
{entry.client_name} - {entry.progress}% + {entry.progress}%
{entry.client_name} - {entry.progress}% + {entry.progress}% - - """ - end end diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index d9290d5a..5427e9b7 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -70,6 +70,7 @@ defmodule MicrowavepropWeb.Router do live_session :public, on_mount: [{MicrowavepropWeb.UserAuth, :default}] do live "/submit", SubmitLive + live "/imports/:id", ImportLive live "/map", MapLive live "/weather", WeatherMapLive live "/contacts", ContactLive.Index diff --git a/test/microwaveprop_web/live/import_live_test.exs b/test/microwaveprop_web/live/import_live_test.exs new file mode 100644 index 00000000..1973c97a --- /dev/null +++ b/test/microwaveprop_web/live/import_live_test.exs @@ -0,0 +1,137 @@ +defmodule MicrowavepropWeb.ImportLiveTest do + use MicrowavepropWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Microwaveprop.Radio.ImportRun + alias Microwaveprop.Repo + + defp insert_run(attrs) do + defaults = %{ + submitter_email: "importer@example.com", + rows: %{"rows" => [], "refinement_ids" => []}, + total_rows: 10, + processed_rows: 0, + imported_count: 0, + refined_count: 0, + error_count: 0, + errors: %{}, + status: "pending" + } + + {:ok, run} = + %ImportRun{} + |> ImportRun.changeset(Map.merge(defaults, attrs)) + |> Repo.insert() + + run + end + + describe "mount" do + test "renders the initial state of a pending import", %{conn: conn} do + run = insert_run(%{total_rows: 25, submitter_email: "alice@example.com"}) + + {:ok, _lv, html} = live(conn, ~p"/imports/#{run.id}") + + assert html =~ "Contact Import" + assert html =~ "alice@example.com" + assert html =~ "25" + assert html =~ ~r/id="import-status-badge"[^>]*>\s*Pending/ + assert html =~ ~s(id="import-progress-bar") + assert html =~ ~s(id="import-imported-count") + assert html =~ ~s(id="import-refined-count") + assert html =~ ~s(id="import-error-count") + end + + test "redirects to /submit with a flash when the import is not found", %{conn: conn} do + missing_id = Ecto.UUID.generate() + + assert {:error, {:live_redirect, %{to: "/submit", flash: flash}}} = + live(conn, ~p"/imports/#{missing_id}") + + assert flash["error"] =~ "Import not found" + end + + test "redirects to /submit for an obviously bad id", %{conn: conn} do + assert {:error, {:live_redirect, %{to: "/submit"}}} = + live(conn, ~p"/imports/not-a-uuid") + end + end + + describe "live updates" do + test "updates counters when an import_progress message is broadcast", %{conn: conn} do + run = insert_run(%{total_rows: 100}) + + {:ok, lv, _html} = live(conn, ~p"/imports/#{run.id}") + + updated = %{ + run + | processed_rows: 50, + imported_count: 48, + refined_count: 1, + error_count: 1, + status: "running", + errors: %{"7" => ["band is invalid"]} + } + + Phoenix.PubSub.broadcast( + Microwaveprop.PubSub, + "csv_import:#{run.id}", + {:import_progress, run.id, updated} + ) + + html = render(lv) + + assert html =~ ~r/id="import-imported-count"[^>]*>\s*48/ + assert html =~ ~r/id="import-refined-count"[^>]*>\s*1/ + assert html =~ ~r/id="import-error-count"[^>]*>\s*1/ + assert html =~ ~r/id="import-status-badge"[^>]*>\s*Running/ + assert html =~ "band is invalid" + end + + test "shows the complete UI once status flips to complete", %{conn: conn} do + run = insert_run(%{total_rows: 3}) + + {:ok, lv, _html} = live(conn, ~p"/imports/#{run.id}") + + done = %{ + run + | processed_rows: 3, + imported_count: 3, + status: "complete", + completed_at: DateTime.utc_now() + } + + Phoenix.PubSub.broadcast( + Microwaveprop.PubSub, + "csv_import:#{run.id}", + {:import_progress, run.id, done} + ) + + html = render(lv) + + assert html =~ "Import complete" + assert html =~ ~s(id="import-complete-panel") + assert html =~ "View contacts" + assert html =~ ~r/id="import-status-badge"[^>]*>\s*Complete/ + end + + test "shows the errors section when error_count > 0", %{conn: conn} do + run = + insert_run(%{ + total_rows: 2, + processed_rows: 2, + imported_count: 1, + error_count: 1, + status: "complete", + errors: %{"2" => ["band is invalid"]} + }) + + {:ok, _lv, html} = live(conn, ~p"/imports/#{run.id}") + + assert html =~ ~s(id="import-errors-section") + assert html =~ "Row 2" + assert html =~ "band is invalid" + end + end +end diff --git a/test/microwaveprop_web/live/submit_live_test.exs b/test/microwaveprop_web/live/submit_live_test.exs index c648c269..f6884ed7 100644 --- a/test/microwaveprop_web/live/submit_live_test.exs +++ b/test/microwaveprop_web/live/submit_live_test.exs @@ -97,12 +97,14 @@ defmodule MicrowavepropWeb.SubmitLiveTest do # Nothing was inserted yet assert Repo.aggregate(Contact, :count) == 0 - confirm_html = - lv - |> element("button[phx-click=confirm_csv]") - |> render_click() + lv + |> element("button[phx-click=confirm_csv]") + |> render_click() - assert confirm_html =~ "1 contact submitted" + {path, _flash} = assert_redirect(lv) + assert path =~ ~r"^/imports/[0-9a-f-]{36}$" + # Oban runs inline, so by the time the redirect fires the chunk + # worker has already inserted the contact. assert Repo.aggregate(Contact, :count) == 1 end @@ -186,12 +188,12 @@ defmodule MicrowavepropWeb.SubmitLiveTest do assert html =~ "EM12KP37" assert html =~ "refine 1 existing" - confirm_html = - lv - |> element("button[phx-click=confirm_csv]") - |> render_click() + lv + |> element("button[phx-click=confirm_csv]") + |> render_click() - assert confirm_html =~ "1 existing contact refined" + {path, _flash} = assert_redirect(lv) + assert path =~ ~r"^/imports/[0-9a-f-]{36}$" assert Repo.aggregate(Contact, :count) == 1 refreshed = Repo.reload(existing) @@ -283,6 +285,47 @@ defmodule MicrowavepropWeb.SubmitLiveTest do assert html =~ "Email is required for CSV upload" end + test "ADIF confirm redirects to /imports/:id", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/submit") + + lv + |> element("[phx-value-tab=adif]") + |> render_click() + + adif_content = + "K5TR" <> + "W5XD" <> + "EM00" <> + "EM12" <> + "10368" <> + "CW" <> + "20260328" <> + "180000" <> + "" + + adif_input = + file_input(lv, "#adif-upload-form", :adif, [ + %{name: "contacts.adi", content: adif_content, type: "application/octet-stream"} + ]) + + render_upload(adif_input, "contacts.adi") + + html = + lv + |> form("#adif-upload-form", %{submitter_email: "test@example.com"}) + |> render_submit() + + assert html =~ "Review before submitting" + + lv + |> element("button[phx-click=confirm_csv]") + |> render_click() + + {path, _flash} = assert_redirect(lv) + assert path =~ ~r"^/imports/[0-9a-f-]{36}$" + assert Repo.aggregate(Contact, :count) == 1 + end + test "handles CSV with no data rows", %{conn: conn} do {:ok, lv, _html} = live(conn, ~p"/submit")