Improve Join description handling for GDPR/DSGVO #521
19 changed files with 796 additions and 25 deletions
|
|
@ -8,11 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **GDPR/DSGVO join-form description** – Custom fields can carry a "join form description" that is shown as the field's label on the public join form, with clickable external links (whole URLs and Markdown `[text](url)`). Useful for presenting a GDPR confirmation with a link to an externally hosted privacy declaration before sign-up.
|
||||||
|
- **Join-form description tooltip in member details** – Custom fields that have a join-form description show an info tooltip (prefixed "Beitrittsformular:") on their label in the member detail view.
|
||||||
|
- **Editable join-form description** – Admins can set a field's join-form description in the custom-field settings, with an inline hint about the supported link syntax.
|
||||||
- **CSV import – groups column** – Members can be assigned to groups during CSV import via a `Groups`/`Gruppen` column; group names that do not exist yet are created automatically, and re-importing the same file does not create duplicate groups.
|
- **CSV import – groups column** – Members can be assigned to groups during CSV import via a `Groups`/`Gruppen` column; group names that do not exist yet are created automatically, and re-importing the same file does not create duplicate groups.
|
||||||
- **CSV import – membership fee type column** – A `Fee Type`/`Beitragsart` column assigns each member's membership fee type; an unknown name falls back to the default fee type and is flagged in the preview with a link to create it.
|
- **CSV import – membership fee type column** – A `Fee Type`/`Beitragsart` column assigns each member's membership fee type; an unknown name falls back to the default fee type and is flagged in the preview with a link to create it.
|
||||||
- **CSV import – mapping preview** – After uploading a file, a preview shows how every column maps (with sample rows and warnings for ignored or unknown columns) and the import only starts once you confirm.
|
- **CSV import – mapping preview** – After uploading a file, a preview shows how every column maps (with sample rows and warnings for ignored or unknown columns) and the import only starts once you confirm.
|
||||||
- **Dynamic CSV import templates** – The EN and DE import-template downloads now include the association's current custom fields instead of a fixed column set.
|
- **Dynamic CSV import templates** – The EN and DE import-template downloads now include the association's current custom fields instead of a fixed column set.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Default GDPR custom field** – The seeded GDPR field was shortened from "Datenschutzerklärung akzeptiert" to "DSGVO" and now ships with a default join-form description (with a placeholder link to replace).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **CSV date round-trip** – Date custom-field values are now exported as ISO-8601 (`YYYY-MM-DD`), so an exported CSV can be re-imported without date-parsing errors.
|
- **CSV date round-trip** – Date custom-field values are now exported as ISO-8601 (`YYYY-MM-DD`), so an exported CSV can be re-imported without date-parsing errors.
|
||||||
- **CSV import – fee-status columns ignored** – Columns such as `Bezahlstatus` / `Membership Fee Status` are always ignored on import and never stored as a custom-field value, even when a custom field of the same name exists.
|
- **CSV import – fee-status columns ignored** – Columns such as `Bezahlstatus` / `Membership Fee Status` are always ignored on import and never stored as a custom-field value, even when a custom field of the same name exists.
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ defmodule Mv.Membership.CustomField do
|
||||||
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
|
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
|
||||||
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation.
|
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation.
|
||||||
- `description` - Optional human-readable description
|
- `description` - Optional human-readable description
|
||||||
|
- `join_description` - Optional label shown for this field on the public join form
|
||||||
|
(e.g., a GDPR confirmation text); supports inline external links. Falls back to `name` when nil.
|
||||||
- `required` - If true, all members must have this custom field (future feature)
|
- `required` - If true, all members must have this custom field (future feature)
|
||||||
- `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted
|
- `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted
|
||||||
|
|
||||||
|
|
@ -61,7 +63,14 @@ defmodule Mv.Membership.CustomField do
|
||||||
end
|
end
|
||||||
|
|
||||||
actions do
|
actions do
|
||||||
default_accept [:name, :value_type, :description, :required, :show_in_overview]
|
default_accept [
|
||||||
|
:name,
|
||||||
|
:value_type,
|
||||||
|
:description,
|
||||||
|
:join_description,
|
||||||
|
:required,
|
||||||
|
:show_in_overview
|
||||||
|
]
|
||||||
|
|
||||||
read :read do
|
read :read do
|
||||||
primary? true
|
primary? true
|
||||||
|
|
@ -69,13 +78,13 @@ defmodule Mv.Membership.CustomField do
|
||||||
end
|
end
|
||||||
|
|
||||||
create :create do
|
create :create do
|
||||||
accept [:name, :value_type, :description, :required, :show_in_overview]
|
accept [:name, :value_type, :description, :join_description, :required, :show_in_overview]
|
||||||
change Mv.Membership.Changes.GenerateSlug
|
change Mv.Membership.Changes.GenerateSlug
|
||||||
validate string_length(:slug, min: 1)
|
validate string_length(:slug, min: 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
update :update do
|
update :update do
|
||||||
accept [:name, :description, :required, :show_in_overview]
|
accept [:name, :description, :join_description, :required, :show_in_overview]
|
||||||
require_atomic? false
|
require_atomic? false
|
||||||
|
|
||||||
validate fn changeset, _context ->
|
validate fn changeset, _context ->
|
||||||
|
|
@ -139,6 +148,15 @@ defmodule Mv.Membership.CustomField do
|
||||||
trim?: true
|
trim?: true
|
||||||
]
|
]
|
||||||
|
|
||||||
|
attribute :join_description, :string,
|
||||||
|
allow_nil?: true,
|
||||||
|
public?: true,
|
||||||
|
description: "Label shown for this field on the public join form; supports external links",
|
||||||
|
constraints: [
|
||||||
|
max_length: 1000,
|
||||||
|
trim?: true
|
||||||
|
]
|
||||||
|
|
||||||
attribute :required, :boolean,
|
attribute :required, :boolean,
|
||||||
default: false,
|
default: false,
|
||||||
allow_nil?: false
|
allow_nil?: false
|
||||||
|
|
|
||||||
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 %>
|
<% end %>
|
||||||
|
|
||||||
<.input field={@form[:description]} type="text" label={gettext("Description")} />
|
<.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[:required]} type="checkbox" label={gettext("Required")} />
|
||||||
<.input
|
<.input
|
||||||
field={@form[:show_in_overview]}
|
field={@form[:show_in_overview]}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ defmodule MvWeb.JoinLive do
|
||||||
alias Ash.Resource.Info
|
alias Ash.Resource.Info
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias Mv.Membership.CustomFieldLookup
|
alias Mv.Membership.CustomFieldLookup
|
||||||
|
alias MvWeb.Helpers.JoinDescriptionRenderer
|
||||||
alias MvWeb.JoinRateLimit
|
alias MvWeb.JoinRateLimit
|
||||||
alias MvWeb.Translations.MemberFields
|
alias MvWeb.Translations.MemberFields
|
||||||
|
|
||||||
|
|
@ -96,14 +97,20 @@ defmodule MvWeb.JoinLive do
|
||||||
class="checkbox checkbox-sm"
|
class="checkbox checkbox-sm"
|
||||||
/>
|
/>
|
||||||
<span class="label-text">
|
<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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div>
|
<div>
|
||||||
<label for={"join-field-#{field.id}"} class="label">
|
<label for={"join-field-#{field.id}"} class="label">
|
||||||
<span class="label-text">
|
<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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -237,6 +244,17 @@ defmodule MvWeb.JoinLive do
|
||||||
|> assign(:form, to_form(params, as: "join"))}
|
|> assign(:form, to_form(params, as: "join"))}
|
||||||
end
|
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
|
defp build_join_fields_with_labels(allowlist) do
|
||||||
member_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
member_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||||
custom_field_by_id = custom_field_map(allowlist, member_field_strings)
|
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
|
defp build_join_field(id, required, member_field_strings, custom_field_by_id) do
|
||||||
if id in member_field_strings do
|
if id in member_field_strings do
|
||||||
label = MemberFields.label(String.to_existing_atom(id))
|
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
|
else
|
||||||
custom_field = Map.get(custom_field_by_id, id)
|
custom_field = Map.get(custom_field_by_id, id)
|
||||||
label = if custom_field, do: custom_field.name, else: gettext("Field")
|
label = if custom_field, do: custom_field.name, else: gettext("Field")
|
||||||
input_type = custom_field_input_type(custom_field && custom_field.value_type)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
defp custom_field_map(allowlist, _member_field_strings) do
|
defp custom_field_map(allowlist, _member_field_strings) do
|
||||||
allowlist
|
allowlist
|
||||||
|> Enum.map(& &1.id)
|
|> 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
|
end
|
||||||
|
|
||||||
defp initial_form_params(join_fields) do
|
defp initial_form_params(join_fields) do
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,19 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
<%= for custom_field <- @custom_fields do %>
|
<%= for custom_field <- @custom_fields do %>
|
||||||
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
|
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
|
||||||
<.data_field label={custom_field.name}>
|
<.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)}
|
{format_custom_field_value(cfv, custom_field.value_type)}
|
||||||
</.data_field>
|
</.data_field>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
@ -605,11 +618,14 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
attr :value, :string, default: nil
|
attr :value, :string, default: nil
|
||||||
attr :class, :string, default: ""
|
attr :class, :string, default: ""
|
||||||
slot :inner_block
|
slot :inner_block
|
||||||
|
slot :label_suffix
|
||||||
|
|
||||||
defp data_field(assigns) do
|
defp data_field(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<dl class={@class}>
|
<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">
|
<dd class="mt-1 text-base-content">
|
||||||
<%= if @inner_block != [] do %>
|
<%= if @inner_block != [] do %>
|
||||||
{render_slot(@inner_block)}
|
{render_slot(@inner_block)}
|
||||||
|
|
|
||||||
|
|
@ -3967,6 +3967,21 @@ msgstr "Zeitraum"
|
||||||
msgid "To"
|
msgid "To"
|
||||||
msgstr "Bis"
|
msgstr "Bis"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Join form:"
|
||||||
|
msgstr "Beitrittsformular:"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Description for join form"
|
||||||
|
msgstr "Beschreibung für das Beitrittsformular"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "You can add links: full addresses (https://…) or as [link text](https://…)."
|
||||||
|
msgstr "Du kannst Links einfügen: ganze Adressen (https://…) oder als [Linktext](https://…)."
|
||||||
|
|
||||||
#: lib/mv/membership/import/member_csv.ex
|
#: lib/mv/membership/import/member_csv.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Fee type '%{name}' not found; using the default fee type."
|
msgid "Fee type '%{name}' not found; using the default fee type."
|
||||||
|
|
|
||||||
|
|
@ -3967,6 +3967,21 @@ msgstr ""
|
||||||
msgid "To"
|
msgid "To"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Join form:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Description for join form"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "You can add links: full addresses (https://…) or as [link text](https://…)."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv/membership/import/member_csv.ex
|
#: lib/mv/membership/import/member_csv.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Fee type '%{name}' not found; using the default fee type."
|
msgid "Fee type '%{name}' not found; using the default fee type."
|
||||||
|
|
|
||||||
|
|
@ -3967,6 +3967,21 @@ msgstr ""
|
||||||
msgid "To"
|
msgid "To"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Join form:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Description for join form"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "You can add links: full addresses (https://…) or as [link text](https://…)."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv/membership/import/member_csv.ex
|
#: lib/mv/membership/import/member_csv.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Fee type '%{name}' not found; using the default fee type."
|
msgid "Fee type '%{name}' not found; using the default fee type."
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
defmodule Mv.Repo.Migrations.AddJoinDescriptionToCustomFields do
|
||||||
|
@moduledoc """
|
||||||
|
Updates resources based on their most recent snapshots.
|
||||||
|
|
||||||
|
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
alter table(:custom_fields) do
|
||||||
|
add :join_description, :text
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
alter table(:custom_fields) do
|
||||||
|
remove :join_description
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -81,9 +81,11 @@ custom_field_configs = [
|
||||||
show_in_overview: true
|
show_in_overview: true
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "Datenschutzerklärung akzeptiert",
|
name: "DSGVO",
|
||||||
value_type: :boolean,
|
value_type: :boolean,
|
||||||
description: "Angabe, ob Datenschutzerklärung akzeptiert wurde",
|
description: "Angabe, ob die Datenschutzerklärung akzeptiert wurde",
|
||||||
|
join_description:
|
||||||
|
"Ich habe die [Datenschutzerklärung](https://example.org/datenschutz) gelesen und akzeptiere sie.",
|
||||||
required: false,
|
required: false,
|
||||||
show_in_overview: false
|
show_in_overview: false
|
||||||
},
|
},
|
||||||
|
|
@ -302,11 +304,15 @@ case Membership.get_settings() do
|
||||||
ArgumentError -> Map.has_key?(vis, k)
|
ArgumentError -> Map.has_key?(vis, k)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
merged =
|
merged =
|
||||||
Enum.reduce(default_hidden_in_overview, visibility_config, fn {key, val}, vis ->
|
Enum.reduce(default_hidden_in_overview, visibility_config, fn {key, val}, vis ->
|
||||||
if has_key.(vis, key), do: vis, else: Map.put(vis, key, val)
|
if has_key.(vis, key), do: vis, else: Map.put(vis, key, val)
|
||||||
end)
|
end)
|
||||||
if merged != visibility_config, do: Map.put(acc, :member_field_visibility, merged), else: acc
|
|
||||||
|
if merged != visibility_config,
|
||||||
|
do: Map.put(acc, :member_field_visibility, merged),
|
||||||
|
else: acc
|
||||||
end)
|
end)
|
||||||
|
|
||||||
if map_size(updates) > 0 do
|
if map_size(updates) > 0 do
|
||||||
|
|
@ -332,9 +338,7 @@ IO.puts(
|
||||||
" - Fee types: 5 (Standard, Ermäßigt, Unterstützer, Fördermitglied, Probemitgliedschaft)"
|
" - Fee types: 5 (Standard, Ermäßigt, Unterstützer, Fördermitglied, Probemitgliedschaft)"
|
||||||
)
|
)
|
||||||
|
|
||||||
IO.puts(
|
IO.puts(" - Custom fields: 6 (Geburtsdatum shown in overview; others hidden by default)")
|
||||||
" - Custom fields: 6 (Geburtsdatum shown in overview; others hidden by default)"
|
|
||||||
)
|
|
||||||
|
|
||||||
IO.puts(" - Roles: 5 (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin)")
|
IO.puts(" - Roles: 5 (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin)")
|
||||||
IO.puts(" - Default fee type: Standard (120€ yearly)")
|
IO.puts(" - Default fee type: Standard (120€ yearly)")
|
||||||
|
|
|
||||||
|
|
@ -431,15 +431,16 @@ end)
|
||||||
all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role)
|
all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role)
|
||||||
find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end
|
find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end
|
||||||
|
|
||||||
# 16 members with 4–6 custom field values each (Geburtsdatum, Datenschutz, SEPA, Rechnungs-E-Mail, IBAN, Stunden)
|
# 16 members with 4–6 custom field values each (Geburtsdatum, DSGVO, SEPA, Rechnungs-E-Mail, IBAN, Stunden)
|
||||||
custom_value_assignments =
|
custom_value_assignments =
|
||||||
Enum.map(1..16, fn n ->
|
Enum.map(1..16, fn n ->
|
||||||
email = "mitglied#{n}@example.de"
|
email = "mitglied#{n}@example.de"
|
||||||
# Vary birth dates and values per index
|
# Vary birth dates and values per index
|
||||||
base_date = Date.add(~D[1970-01-01], 365 * (n + 10) + rem(n * 31, 365))
|
base_date = Date.add(~D[1970-01-01], 365 * (n + 10) + rem(n * 31, 365))
|
||||||
|
|
||||||
values = [
|
values = [
|
||||||
{"Geburtsdatum", %{"_union_type" => "date", "_union_value" => base_date}},
|
{"Geburtsdatum", %{"_union_type" => "date", "_union_value" => base_date}},
|
||||||
{"Datenschutzerklärung akzeptiert",
|
{"DSGVO",
|
||||||
%{"_union_type" => "boolean", "_union_value" => n in [1, 2, 3, 5, 7, 9, 11, 13, 15]}},
|
%{"_union_type" => "boolean", "_union_value" => n in [1, 2, 3, 5, 7, 9, 11, 13, 15]}},
|
||||||
{"SEPA-Mandat", %{"_union_type" => "boolean", "_union_value" => rem(n, 3) != 0}},
|
{"SEPA-Mandat", %{"_union_type" => "boolean", "_union_value" => rem(n, 3) != 0}},
|
||||||
{"Rechnungs-E-Mail",
|
{"Rechnungs-E-Mail",
|
||||||
|
|
@ -448,10 +449,12 @@ custom_value_assignments =
|
||||||
%{
|
%{
|
||||||
"_union_type" => "string",
|
"_union_type" => "string",
|
||||||
"_union_value" =>
|
"_union_value" =>
|
||||||
"DE8937040044#{String.pad_leading(to_string(rem(532013000 + n, 1_000_000_000)), 10, "0")}"
|
"DE8937040044#{String.pad_leading(to_string(rem(532_013_000 + n, 1_000_000_000)), 10, "0")}"
|
||||||
}},
|
}},
|
||||||
{"Stunden ehrenamtlich", %{"_union_type" => "integer", "_union_value" => 5 + rem(n * 3, 25)}}
|
{"Stunden ehrenamtlich",
|
||||||
|
%{"_union_type" => "integer", "_union_value" => 5 + rem(n * 3, 25)}}
|
||||||
]
|
]
|
||||||
|
|
||||||
# Drop 0–2 fields per member so not all have 6 (still ~80% overall filled)
|
# Drop 0–2 fields per member so not all have 6 (still ~80% overall filled)
|
||||||
drop_count = rem(n, 3)
|
drop_count = rem(n, 3)
|
||||||
{email, Enum.take(values, 6 - drop_count)}
|
{email, Enum.take(values, 6 - drop_count)}
|
||||||
|
|
@ -502,19 +505,36 @@ case Membership.get_settings() do
|
||||||
Membership.update_settings(settings, %{
|
Membership.update_settings(settings, %{
|
||||||
join_form_enabled: true,
|
join_form_enabled: true,
|
||||||
join_form_field_ids: settings.join_form_field_ids || default_join_form_field_ids,
|
join_form_field_ids: settings.join_form_field_ids || default_join_form_field_ids,
|
||||||
join_form_field_required: settings.join_form_field_required || default_join_form_field_required
|
join_form_field_required:
|
||||||
|
settings.join_form_field_required || default_join_form_field_required
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
# Membership applications (join requests) for UI testing: 4 submitted, 1 with extra form_data
|
# Membership applications (join requests) for UI testing: 4 submitted, 1 with extra form_data
|
||||||
join_request_configs = [
|
join_request_configs = [
|
||||||
%{email: "antrag1@example.de", first_name: "Sandra", last_name: "Meier", form_data: %{"city" => "Berlin"}},
|
%{
|
||||||
|
email: "antrag1@example.de",
|
||||||
|
first_name: "Sandra",
|
||||||
|
last_name: "Meier",
|
||||||
|
form_data: %{"city" => "Berlin"}
|
||||||
|
},
|
||||||
%{email: "antrag2@example.de", first_name: "Thomas", last_name: "Bauer", form_data: %{}},
|
%{email: "antrag2@example.de", first_name: "Thomas", last_name: "Bauer", form_data: %{}},
|
||||||
%{email: "antrag3@example.de", first_name: "Julia", last_name: "Krause", form_data: %{"city" => "Hamburg", "notes" => "Interesse an Jugendgruppe"}},
|
%{
|
||||||
%{email: "antrag4@example.de", first_name: "Michael", last_name: "Schmitt", form_data: %{"city" => "München"}}
|
email: "antrag3@example.de",
|
||||||
|
first_name: "Julia",
|
||||||
|
last_name: "Krause",
|
||||||
|
form_data: %{"city" => "Hamburg", "notes" => "Interesse an Jugendgruppe"}
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
email: "antrag4@example.de",
|
||||||
|
first_name: "Michael",
|
||||||
|
last_name: "Schmitt",
|
||||||
|
form_data: %{"city" => "München"}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
for config <- join_request_configs do
|
for config <- join_request_configs do
|
||||||
|
|
@ -532,8 +552,15 @@ for config <- join_request_configs do
|
||||||
end
|
end
|
||||||
|
|
||||||
IO.puts("✅ Dev seeds completed.")
|
IO.puts("✅ Dev seeds completed.")
|
||||||
IO.puts(" - Members: 20 with country (mostly Deutschland, 1 Österreich, 1 Schweiz), fee types distributed, 5 with exit date")
|
|
||||||
IO.puts(" - Test users: 4 linked to mitglied1–4 with roles Mitglied, Vorstand, Kassenwart, Buchhaltung")
|
IO.puts(
|
||||||
|
" - Members: 20 with country (mostly Deutschland, 1 Österreich, 1 Schweiz), fee types distributed, 5 with exit date"
|
||||||
|
)
|
||||||
|
|
||||||
|
IO.puts(
|
||||||
|
" - Test users: 4 linked to mitglied1–4 with roles Mitglied, Vorstand, Kassenwart, Buchhaltung"
|
||||||
|
)
|
||||||
|
|
||||||
IO.puts(" - Groups: Vorstand, Jugend, Newsletter (with assignments)")
|
IO.puts(" - Groups: Vorstand, Jugend, Newsletter (with assignments)")
|
||||||
IO.puts(" - Custom field values: ~80% filled (16 members, 4–6 fields each)")
|
IO.puts(" - Custom field values: ~80% filled (16 members, 4–6 fields each)")
|
||||||
IO.puts(" - Join form enabled; 4 membership applications (join requests) for UI testing")
|
IO.puts(" - Join form enabled; 4 membership applications (join requests) for UI testing")
|
||||||
|
|
|
||||||
145
priv/resource_snapshots/repo/custom_fields/20260603000204.json
Normal file
145
priv/resource_snapshots/repo/custom_fields/20260603000204.json
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
{
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"gen_random_uuid()\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "name",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "slug",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "value_type",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "description",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "join_description",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "false",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "required",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "true",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "show_in_overview",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"base_filter": null,
|
||||||
|
"check_constraints": [],
|
||||||
|
"create_table_options": null,
|
||||||
|
"custom_indexes": [],
|
||||||
|
"custom_statements": [],
|
||||||
|
"has_create_action": true,
|
||||||
|
"hash": "2600667D140A2A846F9A848ACEFCADA1F1206950B38EF407B0BB13816E508A2A",
|
||||||
|
"identities": [
|
||||||
|
{
|
||||||
|
"all_tenants?": false,
|
||||||
|
"base_filter": null,
|
||||||
|
"index_name": "custom_fields_unique_name_index",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"type": "atom",
|
||||||
|
"value": "name"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "unique_name",
|
||||||
|
"nils_distinct?": true,
|
||||||
|
"where": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"all_tenants?": false,
|
||||||
|
"base_filter": null,
|
||||||
|
"index_name": "custom_fields_unique_slug_index",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"type": "atom",
|
||||||
|
"value": "slug"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "unique_slug",
|
||||||
|
"nils_distinct?": true,
|
||||||
|
"where": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"repo": "Elixir.Mv.Repo",
|
||||||
|
"schema": null,
|
||||||
|
"table": "custom_fields"
|
||||||
|
}
|
||||||
|
|
@ -159,6 +159,61 @@ defmodule Mv.Membership.CustomFieldValidationTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "join_description" do
|
||||||
|
test "persists join_description when set", %{actor: actor} do
|
||||||
|
assert {:ok, custom_field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "dsgvo_field",
|
||||||
|
value_type: :boolean,
|
||||||
|
join_description: "hereby I confirm the GDPR"
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
assert custom_field.join_description == "hereby I confirm the GDPR"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "defaults to nil when not given", %{actor: actor} do
|
||||||
|
assert {:ok, custom_field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "no_join_desc",
|
||||||
|
value_type: :boolean
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
assert custom_field.join_description == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects join_description longer than 1000 characters", %{actor: actor} do
|
||||||
|
assert {:error, changeset} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "too_long_join_desc",
|
||||||
|
value_type: :boolean,
|
||||||
|
join_description: String.duplicate("a", 1001)
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
assert [%{field: :join_description, message: message}] = changeset.errors
|
||||||
|
assert message =~ "max" or message =~ "length" or message =~ "1000"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "is writable via the update action", %{actor: actor} do
|
||||||
|
{:ok, custom_field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{name: "updatable", value_type: :boolean})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
assert {:ok, updated} =
|
||||||
|
custom_field
|
||||||
|
|> Ash.Changeset.for_update(:update, %{join_description: "Accept the GDPR"})
|
||||||
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
|
assert updated.join_description == "Accept the GDPR"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "name uniqueness" do
|
describe "name uniqueness" do
|
||||||
test "rejects duplicate names", %{actor: actor} do
|
test "rejects duplicate names", %{actor: actor} do
|
||||||
assert {:ok, _} =
|
assert {:ok, _} =
|
||||||
|
|
|
||||||
85
test/mv_web/helpers/join_description_renderer_test.exs
Normal file
85
test/mv_web/helpers/join_description_renderer_test.exs
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
defmodule MvWeb.Helpers.JoinDescriptionRendererTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for the join-description renderer that auto-links raw URLs and Markdown
|
||||||
|
links while escaping all other content.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
use ExUnitProperties
|
||||||
|
|
||||||
|
alias MvWeb.Helpers.JoinDescriptionRenderer
|
||||||
|
|
||||||
|
defp html(value) do
|
||||||
|
value
|
||||||
|
|> JoinDescriptionRenderer.render()
|
||||||
|
|> Phoenix.HTML.safe_to_string()
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "render/1" do
|
||||||
|
test "converts a raw URL to an anchor tag with the standard link class" do
|
||||||
|
result = html("Akzeptiere https://example.com/dsgvo")
|
||||||
|
|
||||||
|
assert result =~ ~s(<a href="https://example.com/dsgvo" class="link link-primary")
|
||||||
|
assert result =~ "https://example.com/dsgvo</a>"
|
||||||
|
assert result =~ "Akzeptiere "
|
||||||
|
end
|
||||||
|
|
||||||
|
test "converts Markdown [text](url) to an anchor tag with the standard link class" do
|
||||||
|
result = html("[Datenschutzerklärung](https://example.com/dsgvo)")
|
||||||
|
|
||||||
|
assert result =~ ~s(<a href="https://example.com/dsgvo" class="link link-primary")
|
||||||
|
assert result =~ ">Datenschutzerklärung</a>"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns an empty safe string for nil input" do
|
||||||
|
assert JoinDescriptionRenderer.render(nil) == {:safe, ""}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "escapes arbitrary HTML in non-link text" do
|
||||||
|
result = html("<script>alert(1)</script>")
|
||||||
|
|
||||||
|
refute result =~ "<script>"
|
||||||
|
assert result =~ "<script>"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not double-link a Markdown link whose URL also looks like a raw URL" do
|
||||||
|
result = html("[Datenschutz](https://example.com/x)")
|
||||||
|
|
||||||
|
# exactly one anchor, no nested anchor for the inner raw URL
|
||||||
|
assert result |> :binary.matches("<a ") |> length() == 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "property: link-free text" do
|
||||||
|
property "preserves non-link text content as HTML-escaped output" do
|
||||||
|
check all(text <- link_free_string()) do
|
||||||
|
result = html(text)
|
||||||
|
|
||||||
|
# No links emitted, and text content equals the HTML-escaped input.
|
||||||
|
refute result =~ "<a "
|
||||||
|
assert result == Phoenix.HTML.html_escape(text) |> Phoenix.HTML.safe_to_string()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "property: well-formed Markdown links" do
|
||||||
|
property "renders every [text](https://...) as a single anchor with verbatim text and url" do
|
||||||
|
check all(
|
||||||
|
label <- string(:alphanumeric, min_length: 1),
|
||||||
|
path <- string(:alphanumeric)
|
||||||
|
) do
|
||||||
|
url = "https://example.com/#{path}"
|
||||||
|
result = html("[#{label}](#{url})")
|
||||||
|
|
||||||
|
assert result =~ ~s(<a href="#{url}" class="link link-primary">#{label}</a>)
|
||||||
|
assert result |> :binary.matches("<a ") |> length() == 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Printable strings that contain no bare URLs and no Markdown-link opening bracket.
|
||||||
|
defp link_free_string do
|
||||||
|
:printable
|
||||||
|
|> string()
|
||||||
|
|> filter(fn s -> not String.contains?(s, "http") and not String.contains?(s, "[") end)
|
||||||
|
end
|
||||||
|
end
|
||||||
102
test/mv_web/live/custom_field_live/form_test.exs
Normal file
102
test/mv_web/live/custom_field_live/form_test.exs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
defmodule MvWeb.CustomFieldLive.FormTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for the CustomFieldLive.FormComponent join_description input.
|
||||||
|
|
||||||
|
Covers that an admin can set and persist a custom field's join_description via
|
||||||
|
the settings edit form.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
alias Mv.Membership.CustomField
|
||||||
|
|
||||||
|
setup do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
admin_role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
|
||||||
|
{:ok, user} =
|
||||||
|
Mv.Accounts.User
|
||||||
|
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||||
|
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
||||||
|
password: "testpassword123"
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
|
{:ok, user} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|
|> Ash.update(actor: system_actor)
|
||||||
|
|
||||||
|
user_with_role = Ash.load!(user, :role, domain: Mv.Accounts, actor: system_actor)
|
||||||
|
conn = log_in_user(build_conn(), user_with_role)
|
||||||
|
session = conn.private[:plug_session] || %{}
|
||||||
|
conn = Plug.Test.init_test_session(conn, Map.put(session, "locale", "en"))
|
||||||
|
%{conn: conn, actor: system_actor}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp log_in_user(conn, user) do
|
||||||
|
conn
|
||||||
|
|> Phoenix.ConnTest.init_test_session(%{})
|
||||||
|
|> AshAuthentication.Plug.Helpers.store_in_session(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp open_edit_form(view, custom_field) do
|
||||||
|
view
|
||||||
|
|> element("tr#custom_fields-#{custom_field.id} td", custom_field.name)
|
||||||
|
|> render_click()
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "join_description input" do
|
||||||
|
test "form shows a join_description input", %{conn: conn, actor: actor} do
|
||||||
|
{:ok, custom_field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{name: "dsgvo_field", value_type: :boolean})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
||||||
|
open_edit_form(view, custom_field)
|
||||||
|
|
||||||
|
assert has_element?(view, "input[name='custom_field[join_description]']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "form shows an info tooltip explaining allowed link syntax", %{conn: conn, actor: actor} do
|
||||||
|
{:ok, custom_field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{name: "dsgvo_field", value_type: :boolean})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
||||||
|
open_edit_form(view, custom_field)
|
||||||
|
|
||||||
|
assert has_element?(
|
||||||
|
view,
|
||||||
|
"[data-testid='join-description-link-hint'] .hero-information-circle"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "form accepts and persists join_description", %{conn: conn, actor: actor} do
|
||||||
|
{:ok, custom_field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{name: "dsgvo_field", value_type: :boolean})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
||||||
|
open_edit_form(view, custom_field)
|
||||||
|
|
||||||
|
view
|
||||||
|
|> form("#custom-field-form-#{custom_field.id}-form", %{
|
||||||
|
"custom_field" => %{
|
||||||
|
"name" => custom_field.name,
|
||||||
|
"join_description" => "Accept the GDPR at https://example.com/dsgvo"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|> render_submit()
|
||||||
|
|
||||||
|
updated = Ash.get!(CustomField, custom_field.id, actor: actor)
|
||||||
|
assert updated.join_description == "Accept the GDPR at https://example.com/dsgvo"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -165,6 +165,36 @@ defmodule MvWeb.JoinLiveTest do
|
||||||
custom_field.name
|
custom_field.name
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@tag role: :unauthenticated
|
||||||
|
test "renders join_description with rendered link as label when set", %{conn: conn} do
|
||||||
|
{:ok, settings} = Membership.get_settings()
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, custom_field} =
|
||||||
|
Membership.create_custom_field(
|
||||||
|
%{
|
||||||
|
name: "DSGVO",
|
||||||
|
value_type: :boolean,
|
||||||
|
join_description: "Akzeptiere die [Datenschutzerklärung](https://example.com/dsgvo)"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Membership.update_settings(settings, %{
|
||||||
|
join_form_enabled: true,
|
||||||
|
join_form_field_ids: ["email", custom_field.id],
|
||||||
|
join_form_field_required: %{"email" => true, custom_field.id => false}
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _view, html} = live(conn, "/join")
|
||||||
|
|
||||||
|
assert html =~
|
||||||
|
~s(<a href="https://example.com/dsgvo" class="link link-primary">Datenschutzerklärung</a>)
|
||||||
|
|
||||||
|
assert html =~ "Akzeptiere die"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "join field input types" do
|
describe "join field input types" do
|
||||||
|
|
|
||||||
|
|
@ -220,4 +220,59 @@ defmodule MvWeb.MemberLive.ShowTest do
|
||||||
assert html =~ "private@example.com"
|
assert html =~ "private@example.com"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "custom field join_description tooltip" do
|
||||||
|
test "shows a tooltip on the custom field label when join_description is set", %{
|
||||||
|
conn: conn,
|
||||||
|
member: member,
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
|
{:ok, custom_field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "DSGVO",
|
||||||
|
value_type: :boolean,
|
||||||
|
join_description: "Accept the privacy policy"
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, html} = live(conn, ~p"/members/#{member}")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-tip*='Accept the privacy policy']")
|
||||||
|
# Tooltip content conveys both the join-form context and the description text.
|
||||||
|
assert has_element?(view, "[data-tip*='Join form:']")
|
||||||
|
assert html =~ "Accept the privacy policy"
|
||||||
|
assert html =~ custom_field.name
|
||||||
|
|
||||||
|
# The info-icon wrapper must center the icon vertically with the label,
|
||||||
|
# matching the flex-items-center idiom used elsewhere (e.g. custom field edit),
|
||||||
|
# so the icon is flush with the label text and not offset downward.
|
||||||
|
assert has_element?(
|
||||||
|
view,
|
||||||
|
"[data-tip*='Accept the privacy policy'].inline-flex.items-center"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows no tooltip on the custom field label when join_description is nil", %{
|
||||||
|
conn: conn,
|
||||||
|
member: member,
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
|
{:ok, _custom_field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "Plain field",
|
||||||
|
value_type: :string
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members/#{member}")
|
||||||
|
|
||||||
|
assert has_element?(view, "dt", "Plain field")
|
||||||
|
# The info-icon tooltip beside the label is only rendered when join_description is set.
|
||||||
|
refute has_element?(view, "[data-testid='join-description-tooltip']")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -123,5 +123,24 @@ defmodule Mv.SeedsTest do
|
||||||
assert mitglied.permission_set_name == "own_data",
|
assert mitglied.permission_set_name == "own_data",
|
||||||
"Mitglied role must have own_data permission set"
|
"Mitglied role must have own_data permission set"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "bootstrap seeds create the DSGVO custom field and not the old long name", %{
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
|
{:ok, custom_fields} = Ash.read(Mv.Membership.CustomField, actor: actor)
|
||||||
|
names = Enum.map(custom_fields, & &1.name)
|
||||||
|
|
||||||
|
assert "DSGVO" in names, "Bootstrap seeds must create a custom field named DSGVO"
|
||||||
|
|
||||||
|
refute "Datenschutzerklärung akzeptiert" in names,
|
||||||
|
"Old long field name must no longer be seeded"
|
||||||
|
|
||||||
|
dsgvo = Enum.find(custom_fields, &(&1.name == "DSGVO"))
|
||||||
|
assert dsgvo.value_type == :boolean
|
||||||
|
|
||||||
|
assert dsgvo.join_description ==
|
||||||
|
"Ich habe die [Datenschutzerklärung](https://example.org/datenschutz) gelesen und akzeptiere sie.",
|
||||||
|
"DSGVO field must be seeded with a default join_description"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue