feat: add membership fee status to columns and dropdown
This commit is contained in:
parent
36e57b24be
commit
e1266944b1
7 changed files with 725 additions and 514 deletions
|
|
@ -41,9 +41,6 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
|||
# RENDER
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Export-only alias; must not appear in dropdown (canonical UI key is membership_fee_status).
|
||||
@payment_status_value "payment_status"
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
all_fields = assigns.all_fields || []
|
||||
|
|
@ -62,7 +59,6 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
|||
label: format_custom_field_label(field, custom_fields)
|
||||
}
|
||||
end))
|
||||
|> Enum.reject(fn item -> item.value == @payment_status_value end)
|
||||
|> Enum.uniq_by(fn item -> item.value end)
|
||||
|
||||
assigns = assign(assigns, :all_items, all_items)
|
||||
|
|
|
|||
|
|
@ -35,8 +35,10 @@ defmodule MvWeb.ImportExportLive do
|
|||
alias Mv.Authorization.Actor
|
||||
alias Mv.Config
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.Import.ImportRunner
|
||||
alias Mv.Membership.Import.MemberCSV
|
||||
alias MvWeb.Authorization
|
||||
alias MvWeb.ImportExportLive.Components
|
||||
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
|
||||
|
|
@ -98,11 +100,11 @@ defmodule MvWeb.ImportExportLive do
|
|||
<%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %>
|
||||
<%!-- CSV Import Section --%>
|
||||
<.form_section title={gettext("Import Members (CSV)")}>
|
||||
{import_info_box(assigns)}
|
||||
{template_links(assigns)}
|
||||
{import_form(assigns)}
|
||||
<%= if @import_status == :running or @import_status == :done do %>
|
||||
{import_progress(assigns)}
|
||||
<Components.custom_fields_notice {assigns} />
|
||||
<Components.template_links {assigns} />
|
||||
<Components.import_form {assigns} />
|
||||
<%= if @import_status == :running or @import_status == :done or @import_status == :error do %>
|
||||
<Components.import_progress {assigns} />
|
||||
<% end %>
|
||||
</.form_section>
|
||||
|
||||
|
|
@ -129,223 +131,6 @@ defmodule MvWeb.ImportExportLive do
|
|||
"""
|
||||
end
|
||||
|
||||
# Renders the info box explaining CSV import requirements
|
||||
defp import_info_box(assigns) do
|
||||
~H"""
|
||||
<div role="note" class="alert alert-info mb-4">
|
||||
<.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, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
|
||||
)}
|
||||
</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
|
||||
|
||||
# Renders template download links
|
||||
defp template_links(assigns) do
|
||||
~H"""
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-base-content/70 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
|
||||
|
||||
# Renders the CSV upload form
|
||||
defp 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"
|
||||
>
|
||||
<div class="form-control">
|
||||
<label for="csv_file" class="label">
|
||||
<span class="label-text">
|
||||
{gettext("CSV File")}
|
||||
</span>
|
||||
</label>
|
||||
<.live_file_input
|
||||
upload={@uploads.csv_file}
|
||||
id="csv_file"
|
||||
class="file-input file-input-bordered w-full"
|
||||
aria-describedby="csv_file_help"
|
||||
/>
|
||||
<p class="label-text-alt mt-1" id="csv_file_help">
|
||||
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<.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
|
||||
|
||||
# Renders import progress and results
|
||||
defp import_progress(assigns) do
|
||||
~H"""
|
||||
<%= if @import_progress do %>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
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 do %>
|
||||
{import_results(assigns)}
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
# Renders import results summary, errors, and warnings
|
||||
defp import_results(assigns) do
|
||||
~H"""
|
||||
<section class="space-y-4" data-testid="import-results-panel">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{gettext("Import Results")}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<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 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">
|
||||
<.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
|
||||
|
||||
@impl true
|
||||
def handle_event("validate_csv_upload", _params, socket) do
|
||||
{:noreply, socket}
|
||||
|
|
@ -436,7 +221,7 @@ defmodule MvWeb.ImportExportLive do
|
|||
@spec start_import(Phoenix.LiveView.Socket.t(), map()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
defp start_import(socket, import_state) do
|
||||
progress = initialize_import_progress(import_state)
|
||||
progress = ImportRunner.initial_progress(import_state, max_errors: @max_errors)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|
|
@ -449,21 +234,6 @@ defmodule MvWeb.ImportExportLive do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Initializes the import progress tracking structure with default values.
|
||||
@spec initialize_import_progress(map()) :: map()
|
||||
defp initialize_import_progress(import_state) do
|
||||
%{
|
||||
inserted: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
warnings: import_state.warnings || [],
|
||||
status: :running,
|
||||
current_chunk: 0,
|
||||
total_chunks: length(import_state.chunks),
|
||||
errors_truncated?: false
|
||||
}
|
||||
end
|
||||
|
||||
# Formats error messages for user-friendly display.
|
||||
#
|
||||
# Handles various error types including Ash errors, maps with message fields,
|
||||
|
|
@ -557,52 +327,8 @@ defmodule MvWeb.ImportExportLive do
|
|||
handle_chunk_error(socket, :processing_failed, idx, reason)
|
||||
end
|
||||
|
||||
# Processes a chunk with error handling and sends result message to LiveView.
|
||||
#
|
||||
# Handles errors from MemberCSV.process_chunk and sends appropriate messages
|
||||
# to the LiveView process for progress tracking.
|
||||
@spec process_chunk_with_error_handling(
|
||||
list(),
|
||||
map(),
|
||||
map(),
|
||||
keyword(),
|
||||
pid(),
|
||||
non_neg_integer()
|
||||
) :: :ok
|
||||
defp process_chunk_with_error_handling(
|
||||
chunk,
|
||||
column_map,
|
||||
custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
) do
|
||||
result =
|
||||
try do
|
||||
MemberCSV.process_chunk(chunk, column_map, custom_field_map, opts)
|
||||
rescue
|
||||
e ->
|
||||
{:error, Exception.message(e)}
|
||||
catch
|
||||
:exit, reason ->
|
||||
{:error, inspect(reason)}
|
||||
|
||||
:throw, reason ->
|
||||
{:error, inspect(reason)}
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, chunk_result} ->
|
||||
send(live_view_pid, {:chunk_done, idx, chunk_result})
|
||||
|
||||
{:error, reason} ->
|
||||
send(live_view_pid, {:chunk_error, idx, reason})
|
||||
end
|
||||
end
|
||||
|
||||
# Starts async task to process a chunk of CSV rows.
|
||||
#
|
||||
# In tests (SQL sandbox mode), runs synchronously to avoid Ecto Sandbox issues.
|
||||
# Starts async task to process a chunk of CSV rows (or runs synchronously in test sandbox).
|
||||
# Locale must be set in the process that runs the chunk (Gettext is process-local); see run_chunk_with_locale/7.
|
||||
@spec start_chunk_processing_task(
|
||||
Phoenix.LiveView.Socket.t(),
|
||||
map(),
|
||||
|
|
@ -613,8 +339,8 @@ defmodule MvWeb.ImportExportLive do
|
|||
chunk = Enum.at(import_state.chunks, idx)
|
||||
actor = ensure_actor_loaded(socket)
|
||||
live_view_pid = self()
|
||||
locale = socket.assigns[:locale] || "de"
|
||||
|
||||
# Process chunk with existing error count for capping
|
||||
opts = [
|
||||
custom_field_lookup: import_state.custom_field_lookup,
|
||||
existing_error_count: length(progress.errors),
|
||||
|
|
@ -622,15 +348,9 @@ defmodule MvWeb.ImportExportLive do
|
|||
actor: actor
|
||||
]
|
||||
|
||||
# Get locale from socket for translations in background tasks
|
||||
locale = socket.assigns[:locale] || "de"
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
if Config.sql_sandbox?() do
|
||||
# Run synchronously in tests to avoid Ecto Sandbox issues with async tasks
|
||||
# In test mode, send the message - it will be processed when render() is called
|
||||
# in the test. The test helper wait_for_import_completion() handles message processing
|
||||
process_chunk_with_error_handling(
|
||||
run_chunk_with_locale(
|
||||
locale,
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
|
|
@ -639,49 +359,38 @@ defmodule MvWeb.ImportExportLive do
|
|||
idx
|
||||
)
|
||||
else
|
||||
# Start async task to process chunk in production
|
||||
# Use start_child for fire-and-forget: no monitor, no Task messages
|
||||
# We only use our own send/2 messages for communication
|
||||
Task.Supervisor.start_child(
|
||||
Mv.TaskSupervisor,
|
||||
build_chunk_processing_task(
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx,
|
||||
locale
|
||||
)
|
||||
fn ->
|
||||
run_chunk_with_locale(
|
||||
locale,
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
)
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Builds the task function for processing a chunk asynchronously.
|
||||
defp build_chunk_processing_task(
|
||||
# Sets Gettext locale in the current process, then processes the chunk.
|
||||
# Must be called in the process that runs the chunk (sync: LiveView process; async: Task process).
|
||||
defp run_chunk_with_locale(
|
||||
locale,
|
||||
chunk,
|
||||
column_map,
|
||||
custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx,
|
||||
locale
|
||||
idx
|
||||
) do
|
||||
fn ->
|
||||
# Set locale in task process for translations
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
process_chunk_with_error_handling(
|
||||
chunk,
|
||||
column_map,
|
||||
custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
)
|
||||
end
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
ImportRunner.process_chunk(chunk, column_map, custom_field_map, opts, live_view_pid, idx)
|
||||
end
|
||||
|
||||
# Handles chunk processing result from async task and schedules the next chunk.
|
||||
|
|
@ -693,20 +402,29 @@ defmodule MvWeb.ImportExportLive do
|
|||
map()
|
||||
) :: {:noreply, Phoenix.LiveView.Socket.t()}
|
||||
defp handle_chunk_result(socket, import_state, progress, idx, chunk_result) do
|
||||
# Merge progress
|
||||
new_progress = merge_progress(progress, chunk_result, idx)
|
||||
new_progress =
|
||||
ImportRunner.merge_progress(progress, chunk_result, idx, max_errors: @max_errors)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:import_progress, new_progress)
|
||||
|> assign(:import_status, new_progress.status)
|
||||
|
||||
# Schedule next chunk or mark as done
|
||||
socket = schedule_next_chunk(socket, idx, length(import_state.chunks))
|
||||
|> maybe_send_next_chunk(idx, length(import_state.chunks))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp maybe_send_next_chunk(socket, current_idx, total_chunks) do
|
||||
case ImportRunner.next_chunk_action(current_idx, total_chunks) do
|
||||
{:send_chunk, next_idx} ->
|
||||
send(self(), {:process_chunk, next_idx})
|
||||
socket
|
||||
|
||||
:done ->
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
# Handles chunk processing errors and updates socket with error status.
|
||||
@spec handle_chunk_error(
|
||||
Phoenix.LiveView.Socket.t(),
|
||||
|
|
@ -715,130 +433,24 @@ defmodule MvWeb.ImportExportLive do
|
|||
any()
|
||||
) :: {:noreply, Phoenix.LiveView.Socket.t()}
|
||||
defp handle_chunk_error(socket, error_type, idx, reason \\ nil) do
|
||||
error_message =
|
||||
case error_type do
|
||||
:invalid_index ->
|
||||
gettext("Invalid chunk index: %{idx}", idx: idx)
|
||||
|
||||
:missing_state ->
|
||||
gettext("Import state is missing. Cannot process chunk %{idx}.", idx: idx)
|
||||
|
||||
:processing_failed ->
|
||||
gettext("Failed to process chunk %{idx}: %{reason}",
|
||||
idx: idx,
|
||||
reason: inspect(reason)
|
||||
)
|
||||
end
|
||||
message = ImportRunner.format_chunk_error(error_type, idx, reason)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:import_status, :error)
|
||||
|> put_flash(:error, error_message)
|
||||
|> put_flash(:error, message)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Consumes uploaded CSV file entries and reads the file content.
|
||||
#
|
||||
# Returns the file content as a binary string or an error tuple.
|
||||
@spec consume_and_read_csv(Phoenix.LiveView.Socket.t()) ::
|
||||
{:ok, String.t()} | {:error, String.t()}
|
||||
defp consume_and_read_csv(socket) do
|
||||
raw = consume_uploaded_entries(socket, :csv_file, &read_file_entry/2)
|
||||
|
||||
case raw do
|
||||
[{:ok, content}] when is_binary(content) ->
|
||||
{:ok, content}
|
||||
|
||||
# Phoenix LiveView test (render_upload) can return raw content list when callback return is treated as value
|
||||
[content] when is_binary(content) ->
|
||||
{:ok, content}
|
||||
|
||||
[{:error, reason}] ->
|
||||
{:error, gettext("Failed to read file: %{reason}", reason: reason)}
|
||||
|
||||
[] ->
|
||||
{:error, gettext("No file was uploaded")}
|
||||
|
||||
_other ->
|
||||
{:error, gettext("Failed to read uploaded file: unexpected format")}
|
||||
end
|
||||
raw = consume_uploaded_entries(socket, :csv_file, &ImportRunner.read_file_entry/2)
|
||||
ImportRunner.parse_consume_result(raw)
|
||||
end
|
||||
|
||||
# Reads a single file entry from the uploaded path
|
||||
@spec read_file_entry(map(), map()) :: {:ok, String.t()} | {:error, String.t()}
|
||||
defp read_file_entry(%{path: path}, _entry) do
|
||||
case File.read(path) do
|
||||
{:ok, content} ->
|
||||
{:ok, content}
|
||||
|
||||
{:error, reason} when is_atom(reason) ->
|
||||
# POSIX error atoms (e.g., :enoent) need to be formatted
|
||||
{:error, :file.format_error(reason)}
|
||||
|
||||
{:error, %File.Error{reason: reason}} ->
|
||||
# File.Error struct with reason atom
|
||||
{:error, :file.format_error(reason)}
|
||||
|
||||
{:error, reason} ->
|
||||
# Fallback for other error types
|
||||
{:error, Exception.message(reason)}
|
||||
end
|
||||
end
|
||||
|
||||
# Merges chunk processing results into the overall import progress.
|
||||
#
|
||||
# Handles error capping, warning merging, and status updates.
|
||||
@spec merge_progress(map(), map(), non_neg_integer()) :: map()
|
||||
defp merge_progress(progress, chunk_result, current_chunk_idx) do
|
||||
# Merge errors with cap of @max_errors overall
|
||||
all_errors = progress.errors ++ chunk_result.errors
|
||||
new_errors = Enum.take(all_errors, @max_errors)
|
||||
errors_truncated? = length(all_errors) > @max_errors
|
||||
|
||||
# Merge warnings (optional dedupe - simple append for now)
|
||||
new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, [])
|
||||
|
||||
# Update status based on whether we're done
|
||||
# current_chunk_idx is 0-based, so after processing chunk 0, we've processed 1 chunk
|
||||
chunks_processed = current_chunk_idx + 1
|
||||
new_status = if chunks_processed >= progress.total_chunks, do: :done, else: :running
|
||||
|
||||
%{
|
||||
inserted: progress.inserted + chunk_result.inserted,
|
||||
failed: progress.failed + chunk_result.failed,
|
||||
errors: new_errors,
|
||||
warnings: new_warnings,
|
||||
status: new_status,
|
||||
current_chunk: chunks_processed,
|
||||
total_chunks: progress.total_chunks,
|
||||
errors_truncated?: errors_truncated? || chunk_result.errors_truncated?
|
||||
}
|
||||
end
|
||||
|
||||
# Schedules the next chunk for processing or marks import as complete.
|
||||
@spec schedule_next_chunk(Phoenix.LiveView.Socket.t(), non_neg_integer(), non_neg_integer()) ::
|
||||
Phoenix.LiveView.Socket.t()
|
||||
defp schedule_next_chunk(socket, current_idx, total_chunks) do
|
||||
next_idx = current_idx + 1
|
||||
|
||||
if next_idx < total_chunks do
|
||||
# Schedule next chunk
|
||||
send(self(), {:process_chunk, next_idx})
|
||||
socket
|
||||
else
|
||||
# All chunks processed - status already set to :done in merge_progress
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
# Determines if the import button should be disabled based on import status and upload state
|
||||
@spec import_button_disabled?(:idle | :running | :done | :error, [map()]) :: boolean()
|
||||
defp import_button_disabled?(:running, _entries), do: true
|
||||
defp import_button_disabled?(_status, []), do: true
|
||||
defp import_button_disabled?(_status, [entry | _]) when not entry.done?, do: true
|
||||
defp import_button_disabled?(_status, _entries), do: false
|
||||
|
||||
# Ensures the actor (user with role) is loaded from socket assigns.
|
||||
#
|
||||
# Note: on_mount :ensure_user_role_loaded already guarantees the role is loaded,
|
||||
|
|
|
|||
272
lib/mv_web/live/import_export_live/components.ex
Normal file
272
lib/mv_web/live/import_export_live/components.ex
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
defmodule MvWeb.ImportExportLive.Components do
|
||||
@moduledoc """
|
||||
Function components for the Import/Export 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">
|
||||
<.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, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
|
||||
)}
|
||||
</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="text-sm text-base-content/70 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"
|
||||
>
|
||||
<div class="form-control">
|
||||
<label for="csv_file" class="label">
|
||||
<span class="label-text">
|
||||
{gettext("CSV File")}
|
||||
</span>
|
||||
</label>
|
||||
<.live_file_input
|
||||
upload={@uploads.csv_file}
|
||||
id="csv_file"
|
||||
class="file-input file-input-bordered w-full"
|
||||
aria-describedby="csv_file_help"
|
||||
/>
|
||||
<p class="label-text-alt mt-1" id="csv_file_help">
|
||||
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<.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
|
||||
|
|
@ -100,7 +100,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
all_available_fields =
|
||||
all_custom_fields
|
||||
|> FieldVisibility.get_all_available_fields()
|
||||
|> dedupe_available_fields()
|
||||
|
||||
initial_selection =
|
||||
FieldVisibility.merge_with_global_settings(
|
||||
|
|
@ -124,6 +123,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign(:boolean_custom_fields, boolean_custom_fields)
|
||||
|> assign(:all_available_fields, all_available_fields)
|
||||
|> assign(:user_field_selection, initial_selection)
|
||||
|> assign(:fields_in_url?, false)
|
||||
|> assign(
|
||||
:member_fields_visible,
|
||||
FieldVisibility.get_visible_member_fields(initial_selection)
|
||||
|
|
@ -245,6 +245,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
new_show_current,
|
||||
socket.assigns.boolean_custom_field_filters
|
||||
)
|
||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false)
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
||||
|
|
@ -351,7 +352,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_filters
|
||||
)
|
||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
|
||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false)
|
||||
|
||||
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
|
||||
end
|
||||
|
|
@ -373,6 +374,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_filters
|
||||
)
|
||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false)
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
||||
|
|
@ -396,6 +398,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_filters
|
||||
)
|
||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false)
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||||
|
|
@ -425,6 +428,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.show_current_cycle,
|
||||
updated_filters
|
||||
)
|
||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false)
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||||
|
|
@ -448,6 +452,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.show_current_cycle,
|
||||
boolean_filters
|
||||
)
|
||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false)
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||||
|
|
@ -537,6 +542,12 @@ defmodule MvWeb.MemberLive.Index do
|
|||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
prev_sig = build_signature(socket)
|
||||
fields_in_url? =
|
||||
case Map.get(params, "fields") do
|
||||
v when is_binary(v) and v != "" -> true
|
||||
_ -> false
|
||||
end
|
||||
|
||||
url_selection = FieldSelection.parse_from_url(params)
|
||||
|
||||
merged_selection =
|
||||
|
|
@ -572,6 +583,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> maybe_update_cycle_status_filter(params)
|
||||
|> maybe_update_boolean_filters(params)
|
||||
|> maybe_update_show_current_cycle(params)
|
||||
|> assign(:fields_in_url?, fields_in_url?)
|
||||
|> assign(:query, params["query"])
|
||||
|> assign(:user_field_selection, final_selection)
|
||||
|> assign(:member_fields_visible, visible_member_fields)
|
||||
|
|
@ -674,37 +686,18 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
defp to_sort_id(field) when is_atom(field), do: :"sort_#{field}"
|
||||
|
||||
defp push_sort_url(socket, field, order) do
|
||||
field_str =
|
||||
if is_atom(field) do
|
||||
Atom.to_string(field)
|
||||
else
|
||||
field
|
||||
end
|
||||
|
||||
query_params =
|
||||
build_query_params(
|
||||
socket.assigns.query,
|
||||
field_str,
|
||||
Atom.to_string(order),
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_filters
|
||||
)
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
||||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||||
end
|
||||
|
||||
defp maybe_add_field_selection(params, nil), do: params
|
||||
|
||||
defp maybe_add_field_selection(params, selection) when is_map(selection) do
|
||||
# Only keep `fields` in the URL when it was already present (bookmark/share),
|
||||
# OR when we intentionally push it via push_field_selection_url/1.
|
||||
defp maybe_add_field_selection(params, selection, true) when is_map(selection) do
|
||||
fields_param = FieldSelection.to_url_param(selection)
|
||||
if fields_param != "", do: Map.put(params, "fields", fields_param), else: params
|
||||
|
||||
cond do
|
||||
fields_param == "" -> Map.delete(params, "fields")
|
||||
true -> Map.put(params, "fields", fields_param)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_add_field_selection(params, _), do: params
|
||||
defp maybe_add_field_selection(params, _selection, _include?), do: params
|
||||
|
||||
defp push_field_selection_url(socket) do
|
||||
query_params =
|
||||
|
|
@ -716,7 +709,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.show_current_cycle,
|
||||
socket.assigns.boolean_custom_field_filters
|
||||
)
|
||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
|
||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection], true)
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
push_patch(socket, to: new_path, replace: true)
|
||||
|
|
@ -1398,6 +1391,12 @@ defmodule MvWeb.MemberLive.Index do
|
|||
member_fields: Enum.map(ordered_member_fields_db, &Atom.to_string/1),
|
||||
computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
|
||||
custom_field_ids: ordered_custom_field_ids,
|
||||
column_order:
|
||||
export_column_order(
|
||||
ordered_member_fields_db,
|
||||
ordered_computed_fields,
|
||||
ordered_custom_field_ids
|
||||
),
|
||||
query: socket.assigns[:query] || nil,
|
||||
sort_field: export_sort_field(socket.assigns[:sort_field]),
|
||||
sort_order: export_sort_order(socket.assigns[:sort_order]),
|
||||
|
|
@ -1420,25 +1419,33 @@ defmodule MvWeb.MemberLive.Index do
|
|||
defp export_sort_order(:asc), do: "asc"
|
||||
defp export_sort_order(:desc), do: "desc"
|
||||
defp export_sort_order(o) when is_binary(o), do: o
|
||||
# Build a single ordered list that matches the table order:
|
||||
# - DB fields in Mv.Constants.member_fields() order (already pre-filtered as ordered_member_fields_db)
|
||||
# - computed fields inserted at the correct position (membership_fee_status after membership_fee_start_date)
|
||||
# - custom fields appended in the same order as table (already ordered_custom_field_ids)
|
||||
defp export_column_order(
|
||||
ordered_member_fields_db,
|
||||
ordered_computed_fields,
|
||||
ordered_custom_field_ids
|
||||
) do
|
||||
db_strings = Enum.map(ordered_member_fields_db, &Atom.to_string/1)
|
||||
computed_strings = Enum.map(ordered_computed_fields, &Atom.to_string/1)
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Internal utility: dedupe dropdown fields defensively
|
||||
# -------------------------------------------------------------
|
||||
# Place membership_fee_status right after membership_fee_start_date if present in export
|
||||
db_with_computed =
|
||||
Enum.flat_map(db_strings, fn f ->
|
||||
if f == "membership_fee_start_date" and "membership_fee_status" in computed_strings do
|
||||
[f, "membership_fee_status"]
|
||||
else
|
||||
[f]
|
||||
end
|
||||
end)
|
||||
|
||||
defp dedupe_available_fields(fields) when is_list(fields) do
|
||||
Enum.uniq_by(fields, fn item ->
|
||||
cond do
|
||||
is_map(item) ->
|
||||
Map.get(item, :key) || Map.get(item, :id) || Map.get(item, :field) || item
|
||||
# Any remaining computed fields not inserted above (future-proof)
|
||||
remaining_computed =
|
||||
computed_strings
|
||||
|> Enum.reject(&(&1 in db_with_computed))
|
||||
|
||||
is_tuple(item) and tuple_size(item) >= 1 ->
|
||||
elem(item, 0)
|
||||
|
||||
true ->
|
||||
item
|
||||
end
|
||||
end)
|
||||
db_with_computed ++ remaining_computed ++ ordered_custom_field_ids
|
||||
end
|
||||
|
||||
defp dedupe_available_fields(other), do: other
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue