Merge branch 'main' into issue/mitgliederverwaltung-420
Integrate current main (CSV import, GDPR join-form description, dependency and tooling bumps) into the bulk-actions-dropdown feature. Gettext catalogs were reconciled with mix gettext.extract --merge; the CHANGELOG Unreleased entries of both sides were combined.
This commit is contained in:
commit
6a6099659b
48 changed files with 3541 additions and 148 deletions
120
lib/mv_web/controllers/import_template_controller.ex
Normal file
120
lib/mv_web/controllers/import_template_controller.ex
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
defmodule MvWeb.ImportTemplateController do
|
||||
@moduledoc """
|
||||
Serves CSV import templates generated on the fly from the current custom fields.
|
||||
|
||||
Two actions provide an English (`en/2`) and a German (`de/2`) template. Each
|
||||
template has a single header row listing the standard member columns followed
|
||||
by every existing custom field name (exact match, as the import expects), plus
|
||||
the importable groups and fee-type columns. A single placeholder example row is
|
||||
included to illustrate the format.
|
||||
|
||||
Both actions require the same authorization as the import page
|
||||
(`can?(:create, Member)`); unauthorized requests are rejected.
|
||||
"""
|
||||
use MvWeb, :controller
|
||||
|
||||
alias Mv.Authorization.Actor
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.Membership.MembersCSV
|
||||
alias MvWeb.Authorization
|
||||
|
||||
# Standard member columns in template order, with their English and German headers
|
||||
# and a placeholder example value. Groups and fee type are importable extras.
|
||||
@columns [
|
||||
{"first name", "Vorname", "John", "Max"},
|
||||
{"last name", "Nachname", "Doe", "Mustermann"},
|
||||
{"email", "E-Mail", "john.doe@example.com", "max.mustermann@example.com"},
|
||||
{"country", "Land", "Germany", "Deutschland"},
|
||||
{"city", "Stadt", "Berlin", "Berlin"},
|
||||
{"street", "Straße", "Main Street", "Hauptstraße"},
|
||||
{"house number", "Hausnummer", "1a", "12"},
|
||||
{"postal_code", "PLZ", "12345", "10115"},
|
||||
{"join_date", "Beitrittsdatum", "2020-01-15", "2020-01-15"},
|
||||
{"exit_date", "Austrittsdatum", "", ""},
|
||||
{"notes", "Notizen", "", ""},
|
||||
{"membership_fee_start_date", "Beitragsbeginn", "", ""},
|
||||
{"Groups", "Gruppen", "", ""},
|
||||
{"Fee Type", "Beitragsart", "", ""}
|
||||
]
|
||||
|
||||
@spec en(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def en(conn, _params) do
|
||||
serve_template(conn, :en, "member_import_en.csv")
|
||||
end
|
||||
|
||||
@spec de(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def de(conn, _params) do
|
||||
serve_template(conn, :de, "member_import_de.csv")
|
||||
end
|
||||
|
||||
defp serve_template(conn, locale, filename) do
|
||||
actor = current_actor(conn)
|
||||
|
||||
if Authorization.can?(actor, :create, Member) do
|
||||
csv = build_csv(locale, actor)
|
||||
|
||||
send_download(conn, {:binary, csv},
|
||||
filename: filename,
|
||||
content_type: "text/csv; charset=utf-8"
|
||||
)
|
||||
else
|
||||
return_forbidden(conn)
|
||||
end
|
||||
end
|
||||
|
||||
defp build_csv(locale, actor) do
|
||||
custom_field_names = custom_field_names(actor)
|
||||
|
||||
header =
|
||||
Enum.map(@columns, &header_for(&1, locale)) ++ custom_field_names
|
||||
|
||||
example =
|
||||
Enum.map(@columns, &example_for(&1, locale)) ++ Enum.map(custom_field_names, fn _ -> "" end)
|
||||
|
||||
[csv_row(header), csv_row(example)]
|
||||
|> Enum.join("\n")
|
||||
end
|
||||
|
||||
defp header_for({en, _de, _ex_en, _ex_de}, :en), do: en
|
||||
defp header_for({_en, de, _ex_en, _ex_de}, :de), do: de
|
||||
|
||||
defp example_for({_en, _de, ex_en, _ex_de}, :en), do: ex_en
|
||||
defp example_for({_en, _de, _ex_en, ex_de}, :de), do: ex_de
|
||||
|
||||
defp custom_field_names(actor) do
|
||||
Mv.Membership.list_custom_fields!(actor: actor)
|
||||
|> Enum.map(& &1.name)
|
||||
end
|
||||
|
||||
# Serializes a row using the semicolon delimiter (the import auto-detects it),
|
||||
# quoting any field that contains a delimiter, quote, or newline.
|
||||
defp csv_row(fields) do
|
||||
Enum.map_join(fields, ";", &escape_field/1)
|
||||
end
|
||||
|
||||
# Neutralizes spreadsheet formula triggers (the same guard the export writer
|
||||
# applies) before RFC 4180 quoting, so a custom-field name like
|
||||
# `=HYPERLINK(...)` is not evaluated when the template is opened.
|
||||
defp escape_field(field) do
|
||||
field = field |> to_string() |> MembersCSV.safe_cell()
|
||||
|
||||
if String.contains?(field, [";", "\"", "\n", "\r"]) do
|
||||
"\"" <> String.replace(field, "\"", "\"\"") <> "\""
|
||||
else
|
||||
field
|
||||
end
|
||||
end
|
||||
|
||||
defp current_actor(conn) do
|
||||
conn.assigns[:current_user]
|
||||
|> Actor.ensure_loaded()
|
||||
end
|
||||
|
||||
defp return_forbidden(conn) do
|
||||
conn
|
||||
|> put_status(403)
|
||||
|> put_resp_content_type("application/json")
|
||||
|> json(%{error: "Forbidden"})
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
70
lib/mv_web/helpers/join_description_renderer.ex
Normal file
70
lib/mv_web/helpers/join_description_renderer.ex
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
defmodule MvWeb.Helpers.JoinDescriptionRenderer do
|
||||
@moduledoc """
|
||||
Renders a custom field's `join_description` into Phoenix-safe HTML for the
|
||||
public join form.
|
||||
|
||||
The renderer auto-links two patterns into `<a href="...">` tags:
|
||||
|
||||
- Markdown links of the form `[text](url)` (processed first)
|
||||
- bare `http(s)://` URLs in the remaining text
|
||||
|
||||
All other content is HTML-escaped: only `<a href="...">` tags are ever
|
||||
emitted, so arbitrary HTML in the input is rendered as inert text. This is a
|
||||
defense-in-depth measure — `join_description` is admin-set content, never
|
||||
end-user input — but the renderer must not become a vector for injecting
|
||||
arbitrary markup.
|
||||
|
||||
Markdown links are matched before bare URLs and their matched region is
|
||||
consumed, so a Markdown link whose URL also looks like a bare URL is linked
|
||||
exactly once (no nested anchors).
|
||||
"""
|
||||
|
||||
@markdown_link ~r/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/
|
||||
@bare_url ~r/(https?:\/\/[^\s<]+)/
|
||||
@bare_url_anchored ~r/\A(https?:\/\/[^\s<]+)\z/
|
||||
|
||||
@doc """
|
||||
Converts `value` to a Phoenix-safe HTML iolist.
|
||||
|
||||
Returns `{:safe, ""}` for `nil`. For a string, returns `{:safe, iolist}` with
|
||||
links rendered and all other text HTML-escaped.
|
||||
"""
|
||||
@spec render(String.t() | nil) :: Phoenix.HTML.safe()
|
||||
def render(nil), do: {:safe, ""}
|
||||
|
||||
def render(value) when is_binary(value) do
|
||||
{:safe, render_segments(value)}
|
||||
end
|
||||
|
||||
# Split on Markdown links first; for each non-Markdown segment, link bare URLs;
|
||||
# everything that is not a link is HTML-escaped.
|
||||
defp render_segments(text) do
|
||||
Regex.split(@markdown_link, text, include_captures: true)
|
||||
|> Enum.map(&render_markdown_or_plain/1)
|
||||
end
|
||||
|
||||
defp render_markdown_or_plain(segment) do
|
||||
case Regex.run(@markdown_link, segment) do
|
||||
[^segment, label, url] -> anchor(url, label)
|
||||
_ -> render_plain(segment)
|
||||
end
|
||||
end
|
||||
|
||||
# Auto-link bare URLs in a plain-text segment, escaping all surrounding text.
|
||||
defp render_plain(segment) do
|
||||
Regex.split(@bare_url, segment, include_captures: true)
|
||||
|> Enum.map(fn part ->
|
||||
if Regex.match?(@bare_url_anchored, part) do
|
||||
anchor(part, part)
|
||||
else
|
||||
escape(part)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp anchor(url, label) do
|
||||
["<a href=\"", escape(url), "\" class=\"link link-primary\">", escape(label), "</a>"]
|
||||
end
|
||||
|
||||
defp escape(text), do: Phoenix.HTML.html_escape(text) |> Phoenix.HTML.safe_to_string()
|
||||
end
|
||||
|
|
@ -91,6 +91,45 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
|||
<% end %>
|
||||
|
||||
<.input field={@form[:description]} type="text" label={gettext("Description")} />
|
||||
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<span class="mb-1 label flex items-center gap-2">
|
||||
{gettext("Description for join form")}
|
||||
<.tooltip
|
||||
content={
|
||||
gettext(
|
||||
"You can add links: full addresses (https://…) or as [link text](https://…)."
|
||||
)
|
||||
}
|
||||
position="right"
|
||||
>
|
||||
<span
|
||||
data-testid="join-description-link-hint"
|
||||
aria-label={
|
||||
gettext(
|
||||
"You can add links: full addresses (https://…) or as [link text](https://…)."
|
||||
)
|
||||
}
|
||||
>
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="w-4 h-4 text-base-content/60 cursor-help"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</.tooltip>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name={@form[:join_description].name}
|
||||
id={@form[:join_description].id}
|
||||
value={Phoenix.HTML.Form.normalize_value("text", @form[:join_description].value)}
|
||||
class="w-full input"
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
|
||||
<.input
|
||||
field={@form[:show_in_overview]}
|
||||
|
|
|
|||
|
|
@ -99,7 +99,12 @@ defmodule MvWeb.ImportLive do
|
|||
<.form_section title={gettext("Choose CSV file")}>
|
||||
<Components.custom_fields_notice {assigns} />
|
||||
<Components.template_links {assigns} />
|
||||
<Components.import_form {assigns} />
|
||||
<%= if @import_status != :preview do %>
|
||||
<Components.import_form {assigns} />
|
||||
<% end %>
|
||||
<%= if @import_status == :preview do %>
|
||||
<Components.preview {assigns} />
|
||||
<% end %>
|
||||
<%= if @import_status == :running or @import_status == :done or @import_status == :error do %>
|
||||
<Components.import_progress {assigns} />
|
||||
<% end %>
|
||||
|
|
@ -133,6 +138,29 @@ defmodule MvWeb.ImportLive do
|
|||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("confirm_import", _params, socket) do
|
||||
case socket.assigns do
|
||||
%{import_state: import_state} when is_map(import_state) ->
|
||||
start_import(socket, import_state)
|
||||
|
||||
_ ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, gettext("No prepared import to confirm. Please upload again."))}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_import", _params, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:import_state, nil)
|
||||
|> assign(:import_progress, nil)
|
||||
|> assign(:import_status, :idle)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Checks if all prerequisites for starting an import are met.
|
||||
#
|
||||
# Validates:
|
||||
|
|
@ -169,10 +197,10 @@ defmodule MvWeb.ImportLive do
|
|||
end
|
||||
end
|
||||
|
||||
# Processes CSV upload and starts import process.
|
||||
# Processes CSV upload and enters the mapping preview.
|
||||
#
|
||||
# Reads the uploaded CSV file, prepares it for import, and initiates
|
||||
# the chunked processing workflow.
|
||||
# Reads the uploaded CSV file and prepares it (read-only at the DB level), then
|
||||
# shows the mapping preview. No member is created until the user confirms.
|
||||
@spec process_csv_upload(Phoenix.LiveView.Socket.t()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
defp process_csv_upload(socket) do
|
||||
|
|
@ -181,7 +209,7 @@ defmodule MvWeb.ImportLive do
|
|||
with {:ok, content} <- consume_and_read_csv(socket),
|
||||
{:ok, import_state} <-
|
||||
MemberCSV.prepare(content, max_rows: Config.csv_import_max_rows(), actor: actor) do
|
||||
start_import(socket, import_state)
|
||||
enter_preview(socket, import_state)
|
||||
else
|
||||
{:error, reason} when is_binary(reason) ->
|
||||
{:noreply,
|
||||
|
|
@ -193,6 +221,19 @@ defmodule MvWeb.ImportLive do
|
|||
end
|
||||
end
|
||||
|
||||
# Shows the mapping preview without starting any processing.
|
||||
@spec enter_preview(Phoenix.LiveView.Socket.t(), map()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
defp enter_preview(socket, import_state) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:import_state, import_state)
|
||||
|> assign(:import_progress, nil)
|
||||
|> assign(:import_status, :preview)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Starts the import process by initializing progress tracking and scheduling the first chunk.
|
||||
@spec start_import(Phoenix.LiveView.Socket.t(), map()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
|
|
@ -263,7 +304,9 @@ defmodule MvWeb.ImportLive do
|
|||
custom_field_lookup: import_state.custom_field_lookup,
|
||||
existing_error_count: length(progress.errors),
|
||||
max_errors: @max_errors,
|
||||
actor: actor
|
||||
actor: actor,
|
||||
fee_type_map: import_state.fee_type_map,
|
||||
groups_found: import_state.groups_found
|
||||
]
|
||||
|
||||
_ =
|
||||
|
|
@ -324,8 +367,11 @@ defmodule MvWeb.ImportLive do
|
|||
new_progress =
|
||||
ImportRunner.merge_progress(progress, chunk_result, idx, max_errors: @max_errors)
|
||||
|
||||
new_import_state = ImportRunner.carry_groups_forward(import_state, chunk_result)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:import_state, new_import_state)
|
||||
|> assign(:import_progress, new_progress)
|
||||
|> assign(:import_status, new_progress.status)
|
||||
|> maybe_send_next_chunk(idx, length(import_state.chunks))
|
||||
|
|
|
|||
|
|
@ -25,7 +25,22 @@ defmodule MvWeb.ImportLive.Components do
|
|||
<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."
|
||||
"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."
|
||||
)}
|
||||
</p>
|
||||
<p class="text-sm mb-2">
|
||||
{gettext(
|
||||
"Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically."
|
||||
)}
|
||||
</p>
|
||||
<p class="text-sm mb-2">
|
||||
{gettext(
|
||||
"Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
|
||||
)}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
{gettext(
|
||||
"Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -44,20 +59,12 @@ defmodule MvWeb.ImportLive.Components do
|
|||
</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"
|
||||
>
|
||||
<.link href={~p"/admin/import/template/en"} 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"
|
||||
>
|
||||
<.link href={~p"/admin/import/template/de"} class="link link-primary">
|
||||
{gettext("German Template")}
|
||||
</.link>
|
||||
</li>
|
||||
|
|
@ -108,6 +115,194 @@ defmodule MvWeb.ImportLive.Components do
|
|||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the mapping preview shown between upload and processing.
|
||||
|
||||
Shows the column-to-role mapping, up to 3 sample rows, and notices for
|
||||
auto-created groups, unresolved fee types, empty fee-type cells, and unknown
|
||||
columns. Nothing is written until the user confirms.
|
||||
"""
|
||||
def preview(assigns) do
|
||||
state = assigns.import_state
|
||||
column_roles = column_roles(state)
|
||||
column_samples = column_samples(state.preview_rows, length(state.headers))
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:column_roles, column_roles)
|
||||
|> assign(:column_samples, column_samples)
|
||||
|
||||
~H"""
|
||||
<section
|
||||
class="mt-4 space-y-4"
|
||||
data-testid="import-preview"
|
||||
aria-labelledby="import-preview-heading"
|
||||
>
|
||||
<h2 id="import-preview-heading" class="text-lg font-semibold">
|
||||
{gettext("Preview import")}
|
||||
</h2>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm w-full" data-testid="preview-mapping-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{gettext("Role")}</th>
|
||||
<th>{gettext("Column")}</th>
|
||||
<th class="text-base-content/60">{gettext("Row 1")}</th>
|
||||
<th class="text-base-content/60">{gettext("Row 2")}</th>
|
||||
<th class="text-base-content/60">{gettext("Row 3")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for {{header, role}, samples} <- Enum.zip(@column_roles, @column_samples) do %>
|
||||
<tr class={role_row_class(role)} data-testid="preview-column-row">
|
||||
<td>
|
||||
<span class={"badge badge-sm #{role_badge_class(role)}"}>
|
||||
{role_label(role)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="font-medium">{header}</td>
|
||||
<%= for sample <- samples do %>
|
||||
<td class="text-base-content/70 max-w-32 truncate">{sample}</td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<%= if @import_state.groups_to_create != [] do %>
|
||||
<div class="alert alert-info" role="note" data-testid="preview-groups-notice">
|
||||
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="text-sm">
|
||||
{gettext("These groups will be created automatically: %{names}",
|
||||
names: Enum.join(@import_state.groups_to_create, ", ")
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @import_state.fee_type_warnings != [] do %>
|
||||
<div class="alert alert-warning" role="alert" data-testid="preview-fee-type-warning">
|
||||
<.icon name="hero-exclamation-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="text-sm">
|
||||
{gettext("Unknown fee types (members get the default): %{names}",
|
||||
names: Enum.join(@import_state.fee_type_warnings, ", ")
|
||||
)}
|
||||
</p>
|
||||
<.link
|
||||
navigate={~p"/membership_fee_settings/new_fee_type"}
|
||||
class="link link-primary text-sm"
|
||||
>
|
||||
{gettext("Create fee type")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @import_state.has_empty_fee_type_cells? do %>
|
||||
<div class="alert alert-info" role="note" data-testid="preview-fee-type-info">
|
||||
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="text-sm">
|
||||
{gettext("Rows with an empty fee type will get the default fee type.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @import_state.warnings != [] do %>
|
||||
<div class="alert alert-warning" role="alert" data-testid="preview-unknown-warning">
|
||||
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<%= for warning <- @import_state.warnings do %>
|
||||
<li>{warning}</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<.link navigate={~p"/admin/datafields"} class="link link-primary text-sm">
|
||||
{gettext("Create custom field")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="confirm_import"
|
||||
variant="primary"
|
||||
data-testid="confirm-import-button"
|
||||
>
|
||||
{gettext("Confirm and Import")}
|
||||
</.button>
|
||||
<.button type="button" phx-click="cancel_import" data-testid="cancel-import-button">
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
|
||||
# Pairs each CSV header with its resolved role for the preview mapping table.
|
||||
defp column_roles(state) do
|
||||
member_indices = MapSet.new(Map.values(state.column_map))
|
||||
custom_indices = MapSet.new(Map.values(state.custom_field_map))
|
||||
ignored_headers = MapSet.new(state.ignored)
|
||||
|
||||
state.headers
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {header, index} ->
|
||||
{header, role_for(index, header, state, member_indices, custom_indices, ignored_headers)}
|
||||
end)
|
||||
end
|
||||
|
||||
defp role_for(index, header, state, member_indices, custom_indices, ignored_headers) do
|
||||
cond do
|
||||
index == state.groups_column_index -> :groups
|
||||
index == state.fee_type_column_index -> :fee_type
|
||||
MapSet.member?(ignored_headers, header) -> :ignored
|
||||
MapSet.member?(member_indices, index) -> :member_field
|
||||
MapSet.member?(custom_indices, index) -> :custom_field
|
||||
true -> :unknown
|
||||
end
|
||||
end
|
||||
|
||||
defp role_label(:member_field), do: gettext("Member field")
|
||||
defp role_label(:custom_field), do: gettext("Custom field")
|
||||
defp role_label(:groups), do: gettext("Groups")
|
||||
defp role_label(:fee_type), do: gettext("Fee type")
|
||||
defp role_label(:ignored), do: gettext("Ignored (system-computed field)")
|
||||
defp role_label(:unknown), do: gettext("Unknown (ignored)")
|
||||
|
||||
defp role_badge_class(:member_field), do: "badge-primary"
|
||||
defp role_badge_class(:custom_field), do: "badge-secondary"
|
||||
defp role_badge_class(:groups), do: "badge-success"
|
||||
defp role_badge_class(:fee_type), do: "badge-warning"
|
||||
defp role_badge_class(:ignored), do: "badge-ghost"
|
||||
defp role_badge_class(:unknown), do: "badge-error"
|
||||
|
||||
defp role_row_class(:ignored), do: "opacity-50"
|
||||
defp role_row_class(:unknown), do: "opacity-50"
|
||||
defp role_row_class(_), do: nil
|
||||
|
||||
defp column_samples([], col_count), do: List.duplicate([], col_count)
|
||||
|
||||
defp column_samples(rows, col_count) do
|
||||
Enum.map(0..(col_count - 1), fn col_idx ->
|
||||
rows
|
||||
|> Enum.map(fn row -> Enum.at(row, col_idx, "") end)
|
||||
|> pad_to(3, "")
|
||||
end)
|
||||
end
|
||||
|
||||
defp pad_to(list, target, fill) do
|
||||
list ++ List.duplicate(fill, max(0, target - length(list)))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders import progress text and, when done or aborted, the import results section.
|
||||
"""
|
||||
|
|
@ -254,8 +449,10 @@ defmodule MvWeb.ImportLive.Components do
|
|||
@doc """
|
||||
Returns whether the Start Import button should be disabled.
|
||||
"""
|
||||
@spec import_button_disabled?(:idle | :running | :done | :error, [map()]) :: boolean()
|
||||
@spec import_button_disabled?(:idle | :preview | :running | :done | :error, [map()]) ::
|
||||
boolean()
|
||||
def import_button_disabled?(:running, _entries), do: true
|
||||
def import_button_disabled?(:preview, _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
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ defmodule MvWeb.JoinLive do
|
|||
alias Ash.Resource.Info
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.CustomFieldLookup
|
||||
alias MvWeb.Helpers.JoinDescriptionRenderer
|
||||
alias MvWeb.JoinRateLimit
|
||||
alias MvWeb.Translations.MemberFields
|
||||
|
||||
|
|
@ -96,14 +97,20 @@ defmodule MvWeb.JoinLive do
|
|||
class="checkbox checkbox-sm"
|
||||
/>
|
||||
<span class="label-text">
|
||||
{field.label}<span :if={field.required} aria-hidden="true"> *</span>
|
||||
{render_field_label(field)}<span
|
||||
:if={field.required}
|
||||
aria-hidden="true"
|
||||
> *</span>
|
||||
</span>
|
||||
</label>
|
||||
<% else %>
|
||||
<div>
|
||||
<label for={"join-field-#{field.id}"} class="label">
|
||||
<span class="label-text">
|
||||
{field.label}<span :if={field.required} aria-hidden="true"> *</span>
|
||||
{render_field_label(field)}<span
|
||||
:if={field.required}
|
||||
aria-hidden="true"
|
||||
> *</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -237,6 +244,17 @@ defmodule MvWeb.JoinLive do
|
|||
|> assign(:form, to_form(params, as: "join"))}
|
||||
end
|
||||
|
||||
# Renders a join field's label. When a custom field has a join_description it is
|
||||
# rendered with auto-linked URLs/Markdown; otherwise the plain field label is used.
|
||||
# Safe: join_description is admin-set settings content, never end-user input, and
|
||||
# JoinDescriptionRenderer escapes all non-link text (only emits <a href> tags).
|
||||
defp render_field_label(%{join_description: join_description})
|
||||
when is_binary(join_description) do
|
||||
JoinDescriptionRenderer.render(join_description)
|
||||
end
|
||||
|
||||
defp render_field_label(%{label: label}), do: label
|
||||
|
||||
defp build_join_fields_with_labels(allowlist) do
|
||||
member_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
custom_field_by_id = custom_field_map(allowlist, member_field_strings)
|
||||
|
|
@ -249,20 +267,36 @@ defmodule MvWeb.JoinLive do
|
|||
defp build_join_field(id, required, member_field_strings, custom_field_by_id) do
|
||||
if id in member_field_strings do
|
||||
label = MemberFields.label(String.to_existing_atom(id))
|
||||
%{id: id, label: label, required: required, input_type: member_field_input_type(id)}
|
||||
|
||||
%{
|
||||
id: id,
|
||||
label: label,
|
||||
required: required,
|
||||
input_type: member_field_input_type(id),
|
||||
join_description: nil
|
||||
}
|
||||
else
|
||||
custom_field = Map.get(custom_field_by_id, id)
|
||||
label = if custom_field, do: custom_field.name, else: gettext("Field")
|
||||
input_type = custom_field_input_type(custom_field && custom_field.value_type)
|
||||
|
||||
%{id: id, label: label, required: required, input_type: input_type}
|
||||
%{
|
||||
id: id,
|
||||
label: label,
|
||||
required: required,
|
||||
input_type: input_type,
|
||||
join_description: custom_field && custom_field.join_description
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp custom_field_map(allowlist, _member_field_strings) do
|
||||
allowlist
|
||||
|> Enum.map(& &1.id)
|
||||
|> CustomFieldLookup.fetch_map_by_ids(authorize?: false, select: [:id, :name, :value_type])
|
||||
|> CustomFieldLookup.fetch_map_by_ids(
|
||||
authorize?: false,
|
||||
select: [:id, :name, :value_type, :join_description]
|
||||
)
|
||||
end
|
||||
|
||||
defp initial_form_params(join_fields) do
|
||||
|
|
|
|||
|
|
@ -235,6 +235,19 @@ defmodule MvWeb.MemberLive.Show do
|
|||
<%= for custom_field <- @custom_fields do %>
|
||||
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
|
||||
<.data_field label={custom_field.name}>
|
||||
<:label_suffix :if={custom_field.join_description}>
|
||||
<.tooltip
|
||||
content={"#{gettext("Join form:")} #{custom_field.join_description}"}
|
||||
wrap_class="ml-1 inline-flex items-center"
|
||||
>
|
||||
<span data-testid="join-description-tooltip">
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="size-3.5 text-base-content/50"
|
||||
/>
|
||||
</span>
|
||||
</.tooltip>
|
||||
</:label_suffix>
|
||||
{format_custom_field_value(cfv, custom_field.value_type)}
|
||||
</.data_field>
|
||||
<% end %>
|
||||
|
|
@ -605,11 +618,14 @@ defmodule MvWeb.MemberLive.Show do
|
|||
attr :value, :string, default: nil
|
||||
attr :class, :string, default: ""
|
||||
slot :inner_block
|
||||
slot :label_suffix
|
||||
|
||||
defp data_field(assigns) do
|
||||
~H"""
|
||||
<dl class={@class}>
|
||||
<dt class="text-sm font-medium text-base-content/70">{@label}</dt>
|
||||
<dt class="text-sm font-medium text-base-content/70 flex items-center">
|
||||
{@label}{render_slot(@label_suffix)}
|
||||
</dt>
|
||||
<dd class="mt-1 text-base-content">
|
||||
<%= if @inner_block != [] do %>
|
||||
{render_slot(@inner_block)}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,10 @@ defmodule MvWeb.Router do
|
|||
# Import (Admin only)
|
||||
live "/admin/import", ImportLive
|
||||
|
||||
# Dynamic CSV import templates (admin only; generated from current custom fields)
|
||||
get "/admin/import/template/en", ImportTemplateController, :en
|
||||
get "/admin/import/template/de", ImportTemplateController, :de
|
||||
|
||||
post "/members/export.csv", MemberExportController, :export
|
||||
post "/members/export.pdf", MemberPdfExportController, :export
|
||||
post "/set_locale", LocaleController, :set_locale
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue