270 lines
8.2 KiB
Elixir
270 lines
8.2 KiB
Elixir
defmodule MvWeb.ImportLive.Components do
|
|
@moduledoc """
|
|
Function components for the Import LiveView: import form, progress, results,
|
|
custom fields notice, and template links. Keeps the main LiveView focused on
|
|
mount/handle_event/handle_info and glue code.
|
|
"""
|
|
use Phoenix.Component
|
|
use Gettext, backend: MvWeb.Gettext
|
|
|
|
import MvWeb.CoreComponents
|
|
|
|
use Phoenix.VerifiedRoutes,
|
|
endpoint: MvWeb.Endpoint,
|
|
router: MvWeb.Router,
|
|
statics: MvWeb.static_paths()
|
|
|
|
@doc """
|
|
Renders the info box explaining that data fields must exist before import
|
|
and linking to Manage Member Data (custom fields).
|
|
"""
|
|
def custom_fields_notice(assigns) do
|
|
~H"""
|
|
<div role="note" class="alert alert-info mb-4 w-xl">
|
|
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
|
<div>
|
|
<p class="text-sm mb-2">
|
|
{gettext(
|
|
"Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
|
|
)}
|
|
</p>
|
|
<p class="text-sm">
|
|
<.link
|
|
href={~p"/settings#custom_fields"}
|
|
class="link"
|
|
data-testid="custom-fields-link"
|
|
>
|
|
{gettext("Manage Member Data")}
|
|
</.link>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Renders download links for English and German CSV templates.
|
|
"""
|
|
def template_links(assigns) do
|
|
~H"""
|
|
<div class="mb-4">
|
|
<p class="mb-2">
|
|
{gettext("Download CSV templates:")}
|
|
</p>
|
|
<ul class="list-disc list-inside space-y-1">
|
|
<li>
|
|
<.link
|
|
href={~p"/templates/member_import_en.csv"}
|
|
download="member_import_en.csv"
|
|
class="link link-primary"
|
|
>
|
|
{gettext("English Template")}
|
|
</.link>
|
|
</li>
|
|
<li>
|
|
<.link
|
|
href={~p"/templates/member_import_de.csv"}
|
|
download="member_import_de.csv"
|
|
class="link link-primary"
|
|
>
|
|
{gettext("German Template")}
|
|
</.link>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Renders the CSV file upload form and Start Import button.
|
|
"""
|
|
def import_form(assigns) do
|
|
~H"""
|
|
<.form
|
|
id="csv-upload-form"
|
|
for={%{}}
|
|
multipart={true}
|
|
phx-change="validate_csv_upload"
|
|
phx-submit="start_import"
|
|
data-testid="csv-upload-form"
|
|
>
|
|
<fieldset class="mb-2 fieldset w-md">
|
|
<label for="csv_file">
|
|
<span class="mb-1 label">{gettext("CSV File")}</span>
|
|
</label>
|
|
<.live_file_input
|
|
upload={@uploads.csv_file}
|
|
id="csv_file"
|
|
class="file-input file-input-bordered"
|
|
aria-describedby="csv_file_help"
|
|
/>
|
|
<p class="text-sm text-base-content/60 mt-2" id="csv_file_help">
|
|
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
|
|
</p>
|
|
</fieldset>
|
|
|
|
<.button
|
|
type="submit"
|
|
phx-disable-with={gettext("Starting import...")}
|
|
variant="primary"
|
|
disabled={import_button_disabled?(@import_status, @uploads.csv_file.entries)}
|
|
data-testid="start-import-button"
|
|
>
|
|
{gettext("Start Import")}
|
|
</.button>
|
|
</.form>
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Renders import progress text and, when done or aborted, the import results section.
|
|
"""
|
|
def import_progress(assigns) do
|
|
~H"""
|
|
<%= if @import_progress do %>
|
|
<div
|
|
role="status"
|
|
aria-live="polite"
|
|
aria-atomic="true"
|
|
class="mt-4"
|
|
data-testid="import-progress-container"
|
|
>
|
|
<%= if @import_progress.status == :running do %>
|
|
<p class="text-sm" data-testid="import-progress-text">
|
|
{gettext("Processing chunk %{current} of %{total}...",
|
|
current: @import_progress.current_chunk,
|
|
total: @import_progress.total_chunks
|
|
)}
|
|
</p>
|
|
<% end %>
|
|
|
|
<%= if @import_progress.status == :done or @import_status == :error do %>
|
|
<.import_results {assigns} />
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Renders import results summary, error list, and warnings.
|
|
Shown when import is done or aborted (:error); heading reflects state.
|
|
"""
|
|
def import_results(assigns) do
|
|
~H"""
|
|
<section
|
|
class="space-y-4"
|
|
data-testid="import-results-panel"
|
|
aria-labelledby="import-results-heading"
|
|
>
|
|
<h2
|
|
id="import-results-heading"
|
|
class="text-lg font-semibold"
|
|
data-testid="import-results-heading"
|
|
>
|
|
<%= if @import_status == :error do %>
|
|
{gettext("Import aborted")}
|
|
<% else %>
|
|
{gettext("Import Results")}
|
|
<% end %>
|
|
</h2>
|
|
|
|
<div class="space-y-4">
|
|
<div data-testid="import-summary">
|
|
<h3 class="text-sm font-semibold mb-2">
|
|
{gettext("Summary")}
|
|
</h3>
|
|
<div class="text-sm space-y-2">
|
|
<p>
|
|
<.icon
|
|
name="hero-check-circle"
|
|
class="size-4 inline mr-1"
|
|
aria-hidden="true"
|
|
/>
|
|
{gettext("Successfully inserted: %{count} member(s)",
|
|
count: @import_progress.inserted
|
|
)}
|
|
</p>
|
|
<%= if @import_progress.failed > 0 do %>
|
|
<p>
|
|
<.icon
|
|
name="hero-exclamation-circle"
|
|
class="size-4 inline mr-1"
|
|
aria-hidden="true"
|
|
/>
|
|
{gettext("Failed: %{count} row(s)", count: @import_progress.failed)}
|
|
</p>
|
|
<% end %>
|
|
<%= if @import_progress.errors_truncated? do %>
|
|
<p>
|
|
<.icon
|
|
name="hero-information-circle"
|
|
class="size-4 inline mr-1"
|
|
aria-hidden="true"
|
|
/>
|
|
{gettext("Error list truncated to %{count} entries", count: @max_errors)}
|
|
</p>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
|
|
<%= if length(@import_progress.errors) > 0 do %>
|
|
<div
|
|
role="alert"
|
|
aria-live="assertive"
|
|
aria-atomic="true"
|
|
data-testid="import-error-list"
|
|
>
|
|
<h3 class="text-sm font-semibold mb-2">
|
|
<.icon
|
|
name="hero-exclamation-circle"
|
|
class="size-4 inline mr-1"
|
|
aria-hidden="true"
|
|
/>
|
|
{gettext("Errors")}
|
|
</h3>
|
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
|
<%= for error <- @import_progress.errors do %>
|
|
<li>
|
|
{gettext("Line %{line}: %{message}",
|
|
line: error.csv_line_number || "?",
|
|
message: error.message || gettext("Unknown error")
|
|
)}
|
|
<%= if error.field do %>
|
|
{gettext(" (Field: %{field})", field: error.field)}
|
|
<% end %>
|
|
</li>
|
|
<% end %>
|
|
</ul>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if length(@import_progress.warnings) > 0 do %>
|
|
<div class="alert alert-warning" role="alert" data-testid="import-warnings">
|
|
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
|
<div>
|
|
<h3 class="font-semibold mb-2">
|
|
{gettext("Warnings")}
|
|
</h3>
|
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
|
<%= for warning <- @import_progress.warnings do %>
|
|
<li>{warning}</li>
|
|
<% end %>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</section>
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Returns whether the Start Import button should be disabled.
|
|
"""
|
|
@spec import_button_disabled?(:idle | :running | :done | :error, [map()]) :: boolean()
|
|
def import_button_disabled?(:running, _entries), do: true
|
|
def import_button_disabled?(_status, []), do: true
|
|
def import_button_disabled?(_status, [entry | _]) when not entry.done?, do: true
|
|
def import_button_disabled?(_status, _entries), do: false
|
|
end
|