feat(import): live /imports/:id progress page + gate upload bars on submit
SubmitLive now hands off to the async pipeline:
- confirm_csv/confirm_adif → CsvImport.enqueue/2 → push_navigate to
/imports/:id. ADIF preview shape matches CSV, so one enqueue call
serves both.
- allow_upload(auto_upload: false) so bytes only stream when the user
clicks submit. Progress bar + percentage now gated on
entry.progress > 0 and < 100 — invisible during file selection,
only rendered while the actual upload is in-flight.
- Old in-page csv_results / @csv_result / reset_csv removed; the
/imports/:id page replaces them.
New ImportLive at /imports/🆔 subscribes to 'csv_import:<id>' PubSub,
shows total/processed/imported/refined/error counters in a daisyUI
stats grid, progress bar, status badge, and a collapsed errors table
when error_count > 0. Missing or malformed run_id redirects to /submit
with a flash.
7 new frontend tests (5 ImportLive, 2 updated SubmitLive), 1902 total,
credo clean.
This commit is contained in:
parent
48833f15be
commit
247d066ec5
5 changed files with 453 additions and 124 deletions
222
lib/microwaveprop_web/live/import_live.ex
Normal file
222
lib/microwaveprop_web/live/import_live.ex
Normal file
|
|
@ -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:<id>"` 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"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<.header>
|
||||
Contact Import
|
||||
<:subtitle>
|
||||
Submitted by <span class="font-mono">{@run.submitter_email}</span>
|
||||
· {@run.total_rows} {pluralize(@run.total_rows, "row")}
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<span id="import-status-badge" class={["badge badge-lg", status_badge_class(@run.status)]}>
|
||||
{status_label(@run.status)}
|
||||
</span>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-sm mb-1 tabular-nums">
|
||||
<span>
|
||||
{@run.processed_rows} / {@run.total_rows} processed
|
||||
</span>
|
||||
<span>{progress_percent(@run)}%</span>
|
||||
</div>
|
||||
<progress
|
||||
id="import-progress-bar"
|
||||
class={["progress w-full", progress_class(@run.status)]}
|
||||
value={@run.processed_rows}
|
||||
max={max(@run.total_rows, 1)}
|
||||
>
|
||||
</progress>
|
||||
</div>
|
||||
|
||||
<div class="stats stats-vertical sm:stats-horizontal shadow w-full">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Imported</div>
|
||||
<div id="import-imported-count" class="stat-value text-success tabular-nums">
|
||||
{@run.imported_count}
|
||||
</div>
|
||||
<div class="stat-desc">New contacts inserted</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Refined</div>
|
||||
<div id="import-refined-count" class="stat-value text-info tabular-nums">
|
||||
{@run.refined_count}
|
||||
</div>
|
||||
<div class="stat-desc">Existing contacts updated</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Errors</div>
|
||||
<div id="import-error-count" class="stat-value text-error tabular-nums">
|
||||
{@run.error_count}
|
||||
</div>
|
||||
<div class="stat-desc">Rows that failed at insert</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@run.status == "complete"}
|
||||
id="import-complete-panel"
|
||||
class="alert alert-success"
|
||||
>
|
||||
<.icon name="hero-check-circle" class="w-6 h-6" />
|
||||
<div>
|
||||
<div class="font-semibold">Import complete</div>
|
||||
<div class="text-sm opacity-90">
|
||||
Enrichment (weather, terrain, propagation) will continue in the background.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@run.status == "failed"}
|
||||
id="import-failed-panel"
|
||||
class="alert alert-error"
|
||||
>
|
||||
<.icon name="hero-exclamation-triangle" class="w-6 h-6" />
|
||||
<span>Import failed before processing all rows.</span>
|
||||
</div>
|
||||
|
||||
<details
|
||||
:if={@run.error_count > 0}
|
||||
id="import-errors-section"
|
||||
class="rounded-box border border-base-300"
|
||||
>
|
||||
<summary class="cursor-pointer p-4 font-semibold">
|
||||
{@run.error_count} {pluralize(@run.error_count, "row")} with errors
|
||||
</summary>
|
||||
<div class="overflow-x-auto p-4 pt-0">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-24">Row</th>
|
||||
<th>Errors</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={{row_num, messages} <- sorted_errors(@run.errors)}>
|
||||
<td class="font-mono">Row {row_num}</td>
|
||||
<td>
|
||||
<div :for={msg <- messages}>{msg}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="flex flex-wrap gap-2 pt-4 border-t border-base-300">
|
||||
<.link navigate={~p"/submit"} class="btn btn-outline">
|
||||
<.icon name="hero-arrow-left" class="w-4 h-4" /> Upload another
|
||||
</.link>
|
||||
<.link
|
||||
:if={@run.status == "complete"}
|
||||
navigate={~p"/contacts"}
|
||||
class="btn btn-primary"
|
||||
>
|
||||
View contacts <.icon name="hero-arrow-right" class="w-4 h-4" />
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
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
|
||||
|
|
@ -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 %>
|
||||
</Layouts.app>
|
||||
|
|
@ -442,7 +440,7 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
<div class="flex items-center justify-between text-xs tabular-nums">
|
||||
<span class="truncate pr-2">{entry.client_name}</span>
|
||||
<span class="flex items-center gap-2 shrink-0">
|
||||
<span>{entry.progress}%</span>
|
||||
<span :if={uploading?(entry)}>{entry.progress}%</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
|
|
@ -455,6 +453,7 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
</span>
|
||||
</div>
|
||||
<progress
|
||||
:if={uploading?(entry)}
|
||||
class="progress progress-primary w-full"
|
||||
value={entry.progress}
|
||||
max="100"
|
||||
|
|
@ -561,7 +560,7 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
<div class="flex items-center justify-between text-xs tabular-nums">
|
||||
<span class="truncate pr-2">{entry.client_name}</span>
|
||||
<span class="flex items-center gap-2 shrink-0">
|
||||
<span>{entry.progress}%</span>
|
||||
<span :if={uploading?(entry)}>{entry.progress}%</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
|
|
@ -574,6 +573,7 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
</span>
|
||||
</div>
|
||||
<progress
|
||||
:if={uploading?(entry)}
|
||||
class="progress progress-primary w-full"
|
||||
value={entry.progress}
|
||||
max="100"
|
||||
|
|
@ -843,20 +843,18 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
defp duplicate_source(:earlier_in_upload), do: "Earlier row in this upload"
|
||||
defp duplicate_source(_), do: "Duplicate"
|
||||
|
||||
defp commit_flash(%{imported: imported, refined: refined}) do
|
||||
imp = length(imported)
|
||||
ref = length(refined)
|
||||
|
||||
case {imp, ref} do
|
||||
{0, n} -> "#{n} #{pluralize(n, "contact")} refined."
|
||||
{n, 0} -> "#{n} #{pluralize(n, "contact")} submitted."
|
||||
{i, r} -> "#{i} submitted, #{r} refined."
|
||||
end
|
||||
end
|
||||
|
||||
defp pluralize(1, word), do: word
|
||||
defp pluralize(_, word), do: word <> "s"
|
||||
|
||||
# An upload entry is actively streaming when its progress is partway
|
||||
# between 0 and 100. With `auto_upload: false` entries sit at
|
||||
# `progress: 0` after the user picks a file and only start streaming
|
||||
# when the form is submitted, so gating the progress bar and percent
|
||||
# text on the 0 < progress < 100 window keeps them hidden until the
|
||||
# upload is actually in flight.
|
||||
defp uploading?(%{progress: progress}), do: progress > 0 and progress < 100
|
||||
defp uploading?(_), do: false
|
||||
|
||||
defp confirm_button_label(valid, 0) do
|
||||
"Looks good — submit #{valid} #{pluralize(valid, "contact")}"
|
||||
end
|
||||
|
|
@ -875,76 +873,4 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
defp confirm_prompt(valid, refined) do
|
||||
"Import #{valid} contacts and refine #{refined} existing?"
|
||||
end
|
||||
|
||||
defp csv_results(assigns) do
|
||||
assigns =
|
||||
assign(assigns,
|
||||
imported_count: length(assigns.result.imported),
|
||||
refined_count: length(Map.get(assigns.result, :refined, [])),
|
||||
error_count: length(assigns.result.errors)
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div class="space-y-6">
|
||||
<div
|
||||
:if={@imported_count > 0}
|
||||
class="alert alert-success p-8 text-center"
|
||||
>
|
||||
<.icon name="hero-check-circle" class="w-16 h-16 text-success mx-auto mb-3" />
|
||||
<div class="text-3xl font-bold tabular-nums">
|
||||
{@imported_count} {if @imported_count == 1, do: "contact", else: "contacts"} submitted
|
||||
</div>
|
||||
<p class="text-sm opacity-70 mt-2">
|
||||
Weather, terrain, and propagation data will be attached shortly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@refined_count > 0}
|
||||
class="alert alert-info p-8 text-center"
|
||||
>
|
||||
<.icon name="hero-sparkles" class="w-16 h-16 text-info mx-auto mb-3" />
|
||||
<div class="text-3xl font-bold tabular-nums">
|
||||
{@refined_count} existing {if @refined_count == 1, do: "contact", else: "contacts"} refined
|
||||
</div>
|
||||
<p class="text-sm opacity-70 mt-2">
|
||||
Grid or mode updates have been applied.
|
||||
Enrichment will re-run for any grid changes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div :if={@error_count > 0} class="space-y-2">
|
||||
<div class="alert alert-error">
|
||||
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
|
||||
<span>
|
||||
{@error_count} {if @error_count == 1, do: "row", else: "rows"} had errors at insert time
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Row</th>
|
||||
<th>Errors</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={{row_num, messages} <- @result.errors}>
|
||||
<td class="font-mono">Row {row_num}</td>
|
||||
<td>
|
||||
<div :for={msg <- messages}>{msg}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-outline" phx-click="reset_csv">
|
||||
Upload another
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
137
test/microwaveprop_web/live/import_live_test.exs
Normal file
137
test/microwaveprop_web/live/import_live_test.exs
Normal file
|
|
@ -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
|
||||
|
|
@ -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 =
|
||||
"<CALL:4>K5TR" <>
|
||||
"<STATION_CALLSIGN:4>W5XD" <>
|
||||
"<GRIDSQUARE:4>EM00" <>
|
||||
"<MY_GRIDSQUARE:4>EM12" <>
|
||||
"<FREQ:5>10368" <>
|
||||
"<MODE:2>CW" <>
|
||||
"<QSO_DATE:8>20260328" <>
|
||||
"<TIME_ON:6>180000" <>
|
||||
"<EOR>"
|
||||
|
||||
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")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue