diff --git a/lib/mv/membership/import/import_runner.ex b/lib/mv/membership/import/import_runner.ex
new file mode 100644
index 0000000..5ff846b
--- /dev/null
+++ b/lib/mv/membership/import/import_runner.ex
@@ -0,0 +1,170 @@
+defmodule Mv.Membership.Import.ImportRunner do
+ @moduledoc """
+ Orchestrates CSV member import: file reading, progress tracking, chunk processing,
+ and error formatting. Used by `MvWeb.ImportExportLive` to keep LiveView thin.
+
+ This module does not depend on Phoenix or LiveView. It provides pure functions for
+ progress/merge and side-effectful helpers (read_file_entry, process_chunk) that
+ are called from the LiveView or from tasks started by it.
+ """
+
+ use Gettext, backend: MvWeb.Gettext
+
+ alias Mv.Membership.Import.MemberCSV
+
+ @default_max_errors 50
+
+ @doc """
+ Reads file content from a Phoenix LiveView upload entry (path).
+
+ Used as the callback for `consume_uploaded_entries/3`. Returns `{:ok, content}` or
+ `{:error, reason}` with a user-friendly string.
+ """
+ @spec read_file_entry(map(), map()) :: {:ok, String.t()} | {:error, String.t()}
+ def read_file_entry(%{path: path}, _entry) do
+ case File.read(path) do
+ {:ok, content} ->
+ {:ok, content}
+
+ {:error, reason} when is_atom(reason) ->
+ {:error, :file.format_error(reason)}
+
+ {:error, %File.Error{reason: reason}} ->
+ {:error, :file.format_error(reason)}
+
+ {:error, reason} ->
+ {:error, Exception.message(reason)}
+ end
+ end
+
+ @doc """
+ Normalizes the result of `consume_uploaded_entries/3` into `{:ok, content}` or `{:error, reason}`.
+
+ Handles both the standard `[{:ok, content}]` and test helpers that may return `[content]`.
+ """
+ @spec parse_consume_result(list()) :: {:ok, String.t()} | {:error, String.t()}
+ def parse_consume_result(raw) do
+ case raw do
+ [{:ok, content}] when is_binary(content) -> {:ok, content}
+ [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
+ end
+
+ @doc """
+ Builds the initial progress map from a prepared import_state.
+ """
+ @spec initial_progress(map(), keyword()) :: map()
+ def initial_progress(import_state, opts \\ []) do
+ _max_errors = Keyword.get(opts, :max_errors, @default_max_errors)
+ total = length(import_state.chunks)
+
+ %{
+ inserted: 0,
+ failed: 0,
+ errors: [],
+ warnings: import_state.warnings || [],
+ status: :running,
+ current_chunk: 0,
+ total_chunks: total,
+ errors_truncated?: false
+ }
+ end
+
+ @doc """
+ Merges a chunk result into the current progress and returns updated progress.
+
+ Caps errors at `max_errors` (default 50). Sets `status` to `:done` when all chunks
+ have been processed.
+ """
+ @spec merge_progress(map(), map(), non_neg_integer(), keyword()) :: map()
+ def merge_progress(progress, chunk_result, current_chunk_idx, opts \\ []) do
+ max_errors = Keyword.get(opts, :max_errors, @default_max_errors)
+
+ all_errors = progress.errors ++ chunk_result.errors
+ new_errors = Enum.take(all_errors, max_errors)
+ errors_truncated? = length(all_errors) > max_errors
+ new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, [])
+
+ 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? || Map.get(chunk_result, :errors_truncated?, false)
+ }
+ end
+
+ @doc """
+ Returns the next action after processing a chunk: send the next chunk index or done.
+ """
+ @spec next_chunk_action(non_neg_integer(), non_neg_integer()) ::
+ {:send_chunk, non_neg_integer()} | :done
+ def next_chunk_action(current_idx, total_chunks) do
+ next_idx = current_idx + 1
+ if next_idx < total_chunks, do: {:send_chunk, next_idx}, else: :done
+ end
+
+ @doc """
+ Processes one chunk (validate + create members), then sends `{:chunk_done, idx, result}`
+ or `{:chunk_error, idx, reason}` to `live_view_pid`.
+
+ Options: `:custom_field_lookup`, `:existing_error_count`, `:max_errors`, `:actor`.
+ """
+ @spec process_chunk(
+ list(),
+ map(),
+ map(),
+ keyword(),
+ pid(),
+ non_neg_integer()
+ ) :: :ok
+ def process_chunk(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
+
+ :ok
+ end
+
+ @doc """
+ Returns a user-facing error message for chunk failures (invalid index, missing state,
+ or processing failure).
+ """
+ @spec format_chunk_error(
+ :invalid_index | :missing_state | :processing_failed,
+ non_neg_integer(),
+ any()
+ ) ::
+ String.t()
+ def format_chunk_error(:invalid_index, idx, _reason) do
+ gettext("Invalid chunk index: %{idx}", idx: idx)
+ end
+
+ def format_chunk_error(:missing_state, idx, _reason) do
+ gettext("Import state is missing. Cannot process chunk %{idx}.", idx: idx)
+ end
+
+ def format_chunk_error(:processing_failed, idx, reason) do
+ gettext("Failed to process chunk %{idx}: %{reason}", idx: idx, reason: inspect(reason))
+ end
+end
diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex
index 5f771cc..e86eb96 100644
--- a/lib/mv/membership/member_export.ex
+++ b/lib/mv/membership/member_export.ex
@@ -16,8 +16,9 @@ defmodule Mv.Membership.MemberExport do
alias MvWeb.MemberLive.Index.MembershipFeeStatus
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
- ["membership_fee_status", "payment_status"]
- @computed_export_fields ["membership_fee_status", "payment_status"]
+ ["membership_fee_status"]
+ @computed_export_fields ["membership_fee_status"]
+ @computed_insert_after "membership_fee_start_date"
@custom_field_prefix Mv.Constants.custom_field_prefix()
@domain_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
@@ -75,9 +76,16 @@ defmodule Mv.Membership.MemberExport do
if f in parsed.selectable_member_fields do
%{kind: :member_field, key: f}
else
- %{kind: :computed, key: String.to_existing_atom(f)}
+ # only allow known computed export fields to avoid crashing on unknown atoms
+ if f in @computed_export_fields do
+ %{kind: :computed, key: String.to_existing_atom(f)}
+ else
+ # ignore unknown non-selectable fields defensively
+ nil
+ end
end
end)
+ |> Enum.reject(&is_nil/1)
custom_specs =
parsed.custom_field_ids
@@ -96,7 +104,8 @@ defmodule Mv.Membership.MemberExport do
need_cycles =
parsed.show_current_cycle or parsed.cycle_status_filter != nil or
- parsed.computed_fields != []
+ parsed.computed_fields != [] or
+ "membership_fee_status" in parsed.member_fields
query =
Member
@@ -143,6 +152,9 @@ defmodule Mv.Membership.MemberExport do
members
end
+ # Calculate membership_fee_status for computed fields
+ members = add_computed_fields(members, parsed.computed_fields, parsed.show_current_cycle)
+
{:ok, members}
{:error, %Ash.Error.Forbidden{}} ->
@@ -241,6 +253,19 @@ defmodule Mv.Membership.MemberExport do
defp apply_cycle_status_filter(members, _status, _show_current), do: members
+ defp add_computed_fields(members, computed_fields, show_current_cycle) do
+ computed_fields = computed_fields || []
+
+ if "membership_fee_status" in computed_fields do
+ Enum.map(members, fn member ->
+ status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
+ Map.put(member, :membership_fee_status, status) # <= Atom rein
+ end)
+ else
+ members
+ end
+ end
+
# Called by controller to build parsed map from raw params (kept here so controller stays thin)
@doc """
Parses and validates export params (from JSON payload).
@@ -251,12 +276,31 @@ defmodule Mv.Membership.MemberExport do
"""
@spec parse_params(map()) :: map()
def parse_params(params) do
- member_fields = filter_allowed_member_fields(extract_list(params, "member_fields"))
- {selectable_member_fields, computed_fields} = split_member_fields(member_fields)
+ # DB fields come from "member_fields"
+ raw_member_fields = extract_list(params, "member_fields")
+ member_fields = filter_allowed_member_fields(raw_member_fields)
+
+ # computed fields can come from "computed_fields" (new payload) OR legacy inclusion in member_fields
+ computed_fields =
+ (extract_list(params, "computed_fields") ++ member_fields)
+ |> normalize_computed_fields()
+ |> Enum.filter(&(&1 in @computed_export_fields))
+ |> Enum.uniq()
+
+ # selectable DB fields: only real domain member fields, ordered like the table
+ selectable_member_fields =
+ member_fields
+ |> Enum.filter(&(&1 in @domain_member_field_strings))
+ |> order_member_fields_like_table()
+
+ # final member_fields list (used for column specs order): table order + computed inserted
+ ordered_member_fields =
+ selectable_member_fields
+ |> insert_computed_fields_like_table(computed_fields)
%{
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
- member_fields: member_fields,
+ member_fields: ordered_member_fields,
selectable_member_fields: selectable_member_fields,
computed_fields: computed_fields,
custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")),
@@ -269,12 +313,6 @@ defmodule Mv.Membership.MemberExport do
}
end
- defp split_member_fields(member_fields) do
- selectable = Enum.filter(member_fields, fn f -> f in @domain_member_field_strings end)
- computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end)
- {selectable, computed}
- end
-
defp extract_boolean(params, key) do
case Map.get(params, key) do
true -> true
@@ -341,4 +379,41 @@ defmodule Mv.Membership.MemberExport do
end)
|> Enum.uniq()
end
+
+ defp order_member_fields_like_table(fields) when is_list(fields) do
+ table_order = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
+ table_order |> Enum.filter(&(&1 in fields))
+ end
+
+ defp insert_computed_fields_like_table(db_fields_ordered, computed_fields) do
+ # Insert membership_fee_status right after membership_fee_start_date (if both selected),
+ # otherwise append at the end of DB fields.
+ computed_fields = computed_fields || []
+
+ db_with_insert =
+ Enum.flat_map(db_fields_ordered, fn f ->
+ if f == @computed_insert_after and "membership_fee_status" in computed_fields do
+ [f, "membership_fee_status"]
+ else
+ [f]
+ end
+ end)
+
+ remaining =
+ computed_fields
+ |> Enum.reject(&(&1 in db_with_insert))
+
+ db_with_insert ++ remaining
+ end
+
+ defp normalize_computed_fields(fields) when is_list(fields) do
+ fields
+ |> Enum.filter(&is_binary/1)
+ |> Enum.map(fn
+ "payment_status" -> "membership_fee_status"
+ other -> other
+ end)
+ end
+
+ defp normalize_computed_fields(_), do: []
end
diff --git a/lib/mv_web/controllers/member_export_controller.ex b/lib/mv_web/controllers/member_export_controller.ex
index 57ce630..6037e9d 100644
--- a/lib/mv_web/controllers/member_export_controller.ex
+++ b/lib/mv_web/controllers/member_export_controller.ex
@@ -14,8 +14,12 @@ defmodule MvWeb.MemberExportController do
alias Mv.Membership.CustomField
alias Mv.Membership.Member
alias Mv.Membership.MembersCSV
+ alias MvWeb.Translations.MemberFields
+ alias MvWeb.MemberLive.Index.MembershipFeeStatus
+ use Gettext, backend: MvWeb.Gettext
@member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
+ @computed_export_fields ["membership_fee_status"]
@custom_field_prefix Mv.Constants.custom_field_prefix()
def export(conn, params) do
@@ -58,17 +62,38 @@ defmodule MvWeb.MemberExportController do
end
defp parse_and_validate(params) do
+ member_fields = filter_allowed_member_fields(extract_list(params, "member_fields"))
+ {selectable_member_fields, computed_fields} = split_member_fields(member_fields)
+
%{
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
- member_fields: filter_allowed_member_fields(extract_list(params, "member_fields")),
- computed_fields: filter_existing_atoms(extract_list(params, "computed_fields")),
+ member_fields: member_fields,
+ selectable_member_fields: selectable_member_fields,
+ computed_fields:
+ computed_fields ++ filter_existing_atoms(extract_list(params, "computed_fields")),
custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")),
query: extract_string(params, "query"),
sort_field: extract_string(params, "sort_field"),
- sort_order: extract_sort_order(params)
+ sort_order: extract_sort_order(params),
+ show_current_cycle: extract_boolean(params, "show_current_cycle")
}
end
+ defp split_member_fields(member_fields) do
+ domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
+ selectable = Enum.filter(member_fields, fn f -> f in domain_fields end)
+ computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end)
+ {selectable, computed}
+ end
+
+ defp extract_boolean(params, key) do
+ case Map.get(params, key) do
+ true -> true
+ "true" -> true
+ _ -> false
+ end
+ end
+
defp filter_existing_atoms(list) when is_list(list) do
list
|> Enum.filter(&is_binary/1)
@@ -198,13 +223,17 @@ defmodule MvWeb.MemberExportController do
end
defp load_members_for_export(actor, parsed, custom_fields_by_id) do
- select_fields = [:id] ++ Enum.map(parsed.member_fields, &String.to_existing_atom/1)
+ select_fields = [:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
+
+ need_cycles =
+ parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields
query =
Member
|> Ash.Query.new()
|> Ash.Query.select(select_fields)
|> load_custom_field_values_query(parsed.custom_field_ids)
+ |> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
query =
if parsed.selected_ids != [] do
@@ -232,6 +261,9 @@ defmodule MvWeb.MemberExportController do
members
end
+ # Calculate membership_fee_status for computed fields
+ members = add_computed_fields(members, parsed.computed_fields, parsed.show_current_cycle)
+
{:ok, members}
{:error, %Ash.Error.Forbidden{}} ->
@@ -239,6 +271,31 @@ defmodule MvWeb.MemberExportController do
end
end
+ defp maybe_load_cycles(query, false, _show_current), do: query
+
+ defp maybe_load_cycles(query, true, show_current) do
+ MembershipFeeStatus.load_cycles_for_members(query, show_current)
+ end
+
+ # Adds computed field values to members (e.g. membership_fee_status)
+ defp add_computed_fields(members, computed_fields, show_current_cycle) do
+ if "membership_fee_status" in computed_fields do
+ Enum.map(members, fn member ->
+ status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
+ status_string = format_membership_fee_status(status)
+ Map.put(member, :membership_fee_status, status_string)
+ end)
+ else
+ members
+ end
+ end
+
+ # Formats membership fee status as German string
+ defp format_membership_fee_status(:paid), do: gettext("paid")
+ defp format_membership_fee_status(:unpaid), do: gettext("unpaid")
+ defp format_membership_fee_status(:suspended), do: gettext("suspended")
+ defp format_membership_fee_status(nil), do: ""
+
defp load_custom_field_values_query(query, []), do: query
defp load_custom_field_values_query(query, custom_field_ids) do
@@ -360,7 +417,7 @@ defmodule MvWeb.MemberExportController do
defp build_columns(conn, parsed, custom_fields_by_id) do
member_cols =
- Enum.map(parsed.member_fields, fn field ->
+ Enum.map(parsed.selectable_member_fields, fn field ->
%{
header: member_field_header(conn, field),
kind: :member_field,
@@ -373,7 +430,7 @@ defmodule MvWeb.MemberExportController do
%{
header: computed_field_header(conn, key),
kind: :computed,
- key: key
+ key: String.to_existing_atom(key)
}
end)
@@ -398,15 +455,36 @@ defmodule MvWeb.MemberExportController do
member_cols ++ computed_cols ++ custom_cols
end
- # --- headers: hier solltest du idealerweise eure bestehenden "display name" Helfer verwenden ---
+ # --- headers: use MemberFields.label for translations ---
defp member_field_header(_conn, field) when is_binary(field) do
- # TODO: hier euren bestehenden display-name helper verwenden (wie Tabelle)
- humanize_field(field)
+ field
+ |> String.to_existing_atom()
+ |> MemberFields.label()
+ rescue
+ ArgumentError ->
+ # Fallback for unknown fields
+ humanize_field(field)
+ end
+
+ defp computed_field_header(_conn, key) when is_atom(key) do
+ # Map export-only alias to canonical UI key for translation
+ atom_key = if key == :payment_status, do: :membership_fee_status, else: key
+ MemberFields.label(atom_key)
end
defp computed_field_header(_conn, key) when is_binary(key) do
- # TODO: display-name helper für computed fields verwenden
- humanize_field(key)
+ # Map export-only alias to canonical UI key for translation
+ atom_key =
+ case key do
+ "payment_status" -> :membership_fee_status
+ _ -> String.to_existing_atom(key)
+ end
+
+ MemberFields.label(atom_key)
+ rescue
+ ArgumentError ->
+ # Fallback for unknown computed fields
+ humanize_field(key)
end
defp custom_field_header(_conn, cf) do
@@ -417,7 +495,8 @@ defmodule MvWeb.MemberExportController do
defp humanize_field(str) do
str
|> String.replace("_", " ")
- |> String.capitalize()
+ |> String.split()
+ |> Enum.map_join(" ", &String.capitalize/1)
end
defp extract_sort_value(%Ash.Union{value: value, type: type}, _),
diff --git a/lib/mv_web/live/components/field_visibility_dropdown_component.ex b/lib/mv_web/live/components/field_visibility_dropdown_component.ex
index 0c9492b..a8e8d45 100644
--- a/lib/mv_web/live/components/field_visibility_dropdown_component.ex
+++ b/lib/mv_web/live/components/field_visibility_dropdown_component.ex
@@ -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)
diff --git a/lib/mv_web/live/import_export_live.ex b/lib/mv_web/live/import_export_live.ex
index 86c3e1f..fe5484c 100644
--- a/lib/mv_web/live/import_export_live.ex
+++ b/lib/mv_web/live/import_export_live.ex
@@ -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)}
+
- {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." - )} -
-- <.link - href={~p"/settings#custom_fields"} - class="link" - data-testid="custom-fields-link" - > - {gettext("Manage Member Data")} - -
-- {gettext("Download CSV templates:")} -
-- {gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)} -
-- {gettext("Processing chunk %{current} of %{total}...", - current: @import_progress.current_chunk, - total: @import_progress.total_chunks - )} -
- <% end %> - - <%= if @import_progress.status == :done do %> - {import_results(assigns)} - <% end %> -- <.icon - name="hero-check-circle" - class="size-4 inline mr-1" - aria-hidden="true" - /> - {gettext("Successfully inserted: %{count} member(s)", - count: @import_progress.inserted - )} -
- <%= if @import_progress.failed > 0 do %> -- <.icon - name="hero-exclamation-circle" - class="size-4 inline mr-1" - aria-hidden="true" - /> - {gettext("Failed: %{count} row(s)", count: @import_progress.failed)} -
- <% end %> - <%= if @import_progress.errors_truncated? do %> -- <.icon - name="hero-information-circle" - class="size-4 inline mr-1" - aria-hidden="true" - /> - {gettext("Error list truncated to %{count} entries", count: @max_errors)} -
- <% end %> -+ {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." + )} +
++ <.link + href={~p"/settings#custom_fields"} + class="link" + data-testid="custom-fields-link" + > + {gettext("Manage Member Data")} + +
++ {gettext("Download CSV templates:")} +
++ {gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)} +
++ {gettext("Processing chunk %{current} of %{total}...", + current: @import_progress.current_chunk, + total: @import_progress.total_chunks + )} +
+ <% end %> + + <%= if @import_progress.status == :done or @import_status == :error do %> + <.import_results {assigns} /> + <% end %> ++ <.icon + name="hero-check-circle" + class="size-4 inline mr-1" + aria-hidden="true" + /> + {gettext("Successfully inserted: %{count} member(s)", + count: @import_progress.inserted + )} +
+ <%= if @import_progress.failed > 0 do %> ++ <.icon + name="hero-exclamation-circle" + class="size-4 inline mr-1" + aria-hidden="true" + /> + {gettext("Failed: %{count} row(s)", count: @import_progress.failed)} +
+ <% end %> + <%= if @import_progress.errors_truncated? do %> ++ <.icon + name="hero-information-circle" + class="size-4 inline mr-1" + aria-hidden="true" + /> + {gettext("Error list truncated to %{count} entries", count: @max_errors)} +
+ <% end %> +