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:
Graham McIntire 2026-04-17 09:31:10 -05:00
parent 48833f15be
commit 247d066ec5
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
5 changed files with 453 additions and 124 deletions

View 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>
&middot; {@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

View file

@ -18,8 +18,18 @@ defmodule MicrowavepropWeb.SubmitLive do
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
socket = socket =
socket socket
|> allow_upload(:csv, accept: ~w(.csv), max_entries: 1, max_file_size: 10_000_000) |> allow_upload(:csv,
|> allow_upload(:adif, accept: :any, max_entries: 1, max_file_size: 10_000_000) 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{}) changeset = Radio.change_contact(%Contact{})
@ -32,7 +42,6 @@ defmodule MicrowavepropWeb.SubmitLive do
submitted_at: nil, submitted_at: nil,
active_tab: :single, active_tab: :single,
csv_preview: nil, csv_preview: nil,
csv_result: nil,
csv_email: "" csv_email: ""
) )
@ -163,15 +172,14 @@ defmodule MicrowavepropWeb.SubmitLive do
def handle_event("confirm_csv", _params, socket) do def handle_event("confirm_csv", _params, socket) do
case socket.assigns.csv_preview do case socket.assigns.csv_preview do
%{valid: valid_rows, refinements: refinements} %{valid: valid_rows, refinements: refinements} = preview
when valid_rows != [] or refinements != [] -> when valid_rows != [] or refinements != [] ->
{:ok, commit_result} = {:ok, run_id} = CsvImport.enqueue(preview, socket.assigns.csv_email)
CsvImport.commit(%{valid: valid_rows, refinements: refinements})
{:noreply, {:noreply,
socket socket
|> assign(csv_preview: nil, csv_result: commit_result) |> assign(csv_preview: nil)
|> put_flash(:info, commit_flash(commit_result))} |> push_navigate(to: ~p"/imports/#{run_id}")}
_ -> _ ->
{:noreply, put_flash(socket, :error, "Nothing to import.")} {:noreply, put_flash(socket, :error, "Nothing to import.")}
@ -179,17 +187,13 @@ defmodule MicrowavepropWeb.SubmitLive do
end end
def handle_event("cancel_csv", _params, socket) do def handle_event("cancel_csv", _params, socket) do
{:noreply, assign(socket, csv_preview: nil, csv_result: nil)} {:noreply, assign(socket, csv_preview: nil)}
end
def handle_event("reset_csv", _params, socket) do
{:noreply, assign(socket, csv_preview: nil, csv_result: nil)}
end end
defp handle_adif_preview(content, email, socket) do defp handle_adif_preview(content, email, socket) do
case AdifImport.preview(content, email) do case AdifImport.preview(content, email) do
{:ok, preview} -> {: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} -> {:error, :no_records} ->
{:noreply, put_flash(socket, :error, "ADIF file contains 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 defp handle_csv_preview(content, email, socket) do
case CsvImport.preview(content, email) do case CsvImport.preview(content, email) do
{:ok, preview} -> {: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} -> {:error, :empty_csv} ->
{:noreply, put_flash(socket, :error, "CSV file is empty")} {:noreply, put_flash(socket, :error, "CSV file is empty")}
@ -297,22 +301,16 @@ defmodule MicrowavepropWeb.SubmitLive do
current_user={current_user(assigns)} current_user={current_user(assigns)}
/> />
<% :csv -> %> <% :csv -> %>
<%= cond do %> <%= if @csv_preview do %>
<% @csv_result -> %> <.csv_preview preview={@csv_preview} />
<.csv_results result={@csv_result} /> <% else %>
<% @csv_preview -> %> <.csv_upload_form uploads={@uploads} current_user={current_user(assigns)} />
<.csv_preview preview={@csv_preview} />
<% true -> %>
<.csv_upload_form uploads={@uploads} current_user={current_user(assigns)} />
<% end %> <% end %>
<% :adif -> %> <% :adif -> %>
<%= cond do %> <%= if @csv_preview do %>
<% @csv_result -> %> <.csv_preview preview={@csv_preview} />
<.csv_results result={@csv_result} /> <% else %>
<% @csv_preview -> %> <.adif_upload_form uploads={@uploads} current_user={current_user(assigns)} />
<.csv_preview preview={@csv_preview} />
<% true -> %>
<.adif_upload_form uploads={@uploads} current_user={current_user(assigns)} />
<% end %> <% end %>
<% end %> <% end %>
</Layouts.app> </Layouts.app>
@ -442,7 +440,7 @@ defmodule MicrowavepropWeb.SubmitLive do
<div class="flex items-center justify-between text-xs tabular-nums"> <div class="flex items-center justify-between text-xs tabular-nums">
<span class="truncate pr-2">{entry.client_name}</span> <span class="truncate pr-2">{entry.client_name}</span>
<span class="flex items-center gap-2 shrink-0"> <span class="flex items-center gap-2 shrink-0">
<span>{entry.progress}%</span> <span :if={uploading?(entry)}>{entry.progress}%</span>
<button <button
type="button" type="button"
class="btn btn-ghost btn-xs" class="btn btn-ghost btn-xs"
@ -455,6 +453,7 @@ defmodule MicrowavepropWeb.SubmitLive do
</span> </span>
</div> </div>
<progress <progress
:if={uploading?(entry)}
class="progress progress-primary w-full" class="progress progress-primary w-full"
value={entry.progress} value={entry.progress}
max="100" max="100"
@ -561,7 +560,7 @@ defmodule MicrowavepropWeb.SubmitLive do
<div class="flex items-center justify-between text-xs tabular-nums"> <div class="flex items-center justify-between text-xs tabular-nums">
<span class="truncate pr-2">{entry.client_name}</span> <span class="truncate pr-2">{entry.client_name}</span>
<span class="flex items-center gap-2 shrink-0"> <span class="flex items-center gap-2 shrink-0">
<span>{entry.progress}%</span> <span :if={uploading?(entry)}>{entry.progress}%</span>
<button <button
type="button" type="button"
class="btn btn-ghost btn-xs" class="btn btn-ghost btn-xs"
@ -574,6 +573,7 @@ defmodule MicrowavepropWeb.SubmitLive do
</span> </span>
</div> </div>
<progress <progress
:if={uploading?(entry)}
class="progress progress-primary w-full" class="progress progress-primary w-full"
value={entry.progress} value={entry.progress}
max="100" 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(:earlier_in_upload), do: "Earlier row in this upload"
defp duplicate_source(_), do: "Duplicate" 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(1, word), do: word
defp pluralize(_, word), do: word <> "s" 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 defp confirm_button_label(valid, 0) do
"Looks good — submit #{valid} #{pluralize(valid, "contact")}" "Looks good — submit #{valid} #{pluralize(valid, "contact")}"
end end
@ -875,76 +873,4 @@ defmodule MicrowavepropWeb.SubmitLive do
defp confirm_prompt(valid, refined) do defp confirm_prompt(valid, refined) do
"Import #{valid} contacts and refine #{refined} existing?" "Import #{valid} contacts and refine #{refined} existing?"
end 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 end

View file

@ -70,6 +70,7 @@ defmodule MicrowavepropWeb.Router do
live_session :public, on_mount: [{MicrowavepropWeb.UserAuth, :default}] do live_session :public, on_mount: [{MicrowavepropWeb.UserAuth, :default}] do
live "/submit", SubmitLive live "/submit", SubmitLive
live "/imports/:id", ImportLive
live "/map", MapLive live "/map", MapLive
live "/weather", WeatherMapLive live "/weather", WeatherMapLive
live "/contacts", ContactLive.Index live "/contacts", ContactLive.Index

View 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

View file

@ -97,12 +97,14 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
# Nothing was inserted yet # Nothing was inserted yet
assert Repo.aggregate(Contact, :count) == 0 assert Repo.aggregate(Contact, :count) == 0
confirm_html = lv
lv |> element("button[phx-click=confirm_csv]")
|> element("button[phx-click=confirm_csv]") |> render_click()
|> 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 assert Repo.aggregate(Contact, :count) == 1
end end
@ -186,12 +188,12 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
assert html =~ "EM12KP37" assert html =~ "EM12KP37"
assert html =~ "refine 1 existing" assert html =~ "refine 1 existing"
confirm_html = lv
lv |> element("button[phx-click=confirm_csv]")
|> element("button[phx-click=confirm_csv]") |> render_click()
|> 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 assert Repo.aggregate(Contact, :count) == 1
refreshed = Repo.reload(existing) refreshed = Repo.reload(existing)
@ -283,6 +285,47 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
assert html =~ "Email is required for CSV upload" assert html =~ "Email is required for CSV upload"
end 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 test "handles CSV with no data rows", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/submit") {:ok, lv, _html} = live(conn, ~p"/submit")