270 lines
8.3 KiB
Elixir
270 lines
8.3 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 with a warning. 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
|