diff --git a/CHANGELOG.md b/CHANGELOG.md index edb53f9..74d015d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### 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 – 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. - **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 - **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. diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index ef6c79a..5f4dd0e 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -12,6 +12,8 @@ defmodule Mv.Membership.CustomField do - `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. - `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) - `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 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 primary? true @@ -69,13 +78,13 @@ defmodule Mv.Membership.CustomField do end 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 validate string_length(:slug, min: 1) end update :update do - accept [:name, :description, :required, :show_in_overview] + accept [:name, :description, :join_description, :required, :show_in_overview] require_atomic? false validate fn changeset, _context -> @@ -139,6 +148,15 @@ defmodule Mv.Membership.CustomField do 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, default: false, allow_nil?: false diff --git a/lib/mv_web/helpers/join_description_renderer.ex b/lib/mv_web/helpers/join_description_renderer.ex new file mode 100644 index 0000000..4c4705f --- /dev/null +++ b/lib/mv_web/helpers/join_description_renderer.ex @@ -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 `` 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 `` 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 + ["", escape(label), ""] + end + + defp escape(text), do: Phoenix.HTML.html_escape(text) |> Phoenix.HTML.safe_to_string() +end diff --git a/lib/mv_web/live/custom_field_live/form_component.ex b/lib/mv_web/live/custom_field_live/form_component.ex index 2e98aeb..1663b4e 100644 --- a/lib/mv_web/live/custom_field_live/form_component.ex +++ b/lib/mv_web/live/custom_field_live/form_component.ex @@ -91,6 +91,45 @@ defmodule MvWeb.CustomFieldLive.FormComponent do <% end %> <.input field={@form[:description]} type="text" label={gettext("Description")} /> + +
+ +
+ <.input field={@form[:required]} type="checkbox" label={gettext("Required")} /> <.input field={@form[:show_in_overview]} diff --git a/lib/mv_web/live/join_live.ex b/lib/mv_web/live/join_live.ex index b679127..2c37f3a 100644 --- a/lib/mv_web/live/join_live.ex +++ b/lib/mv_web/live/join_live.ex @@ -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" /> - {field.label} + {render_field_label(field)} <% else %>
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 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 diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index 52b8250..5ac4fee 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -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" + > + + <.icon + name="hero-information-circle" + class="size-3.5 text-base-content/50" + /> + + + {format_custom_field_value(cfv, custom_field.value_type)} <% 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"""
-
{@label}
+
+ {@label}{render_slot(@label_suffix)} +
<%= if @inner_block != [] do %> {render_slot(@inner_block)} diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 343ede8..ca8edaf 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3967,6 +3967,21 @@ msgstr "Zeitraum" msgid "To" 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 #, elixir-autogen, elixir-format msgid "Fee type '%{name}' not found; using the default fee type." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index f14f7a1..f301985 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3967,6 +3967,21 @@ msgstr "" msgid "To" 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 #, elixir-autogen, elixir-format msgid "Fee type '%{name}' not found; using the default fee type." diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 18d1e30..09c586c 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3967,6 +3967,21 @@ msgstr "" msgid "To" 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 #, elixir-autogen, elixir-format msgid "Fee type '%{name}' not found; using the default fee type." diff --git a/priv/repo/migrations/20260603000531_add_join_description_to_custom_fields.exs b/priv/repo/migrations/20260603000531_add_join_description_to_custom_fields.exs new file mode 100644 index 0000000..b1d9a05 --- /dev/null +++ b/priv/repo/migrations/20260603000531_add_join_description_to_custom_fields.exs @@ -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 diff --git a/priv/repo/seeds_bootstrap.exs b/priv/repo/seeds_bootstrap.exs index 9947704..e3430ec 100644 --- a/priv/repo/seeds_bootstrap.exs +++ b/priv/repo/seeds_bootstrap.exs @@ -81,9 +81,11 @@ custom_field_configs = [ show_in_overview: true }, %{ - name: "Datenschutzerklärung akzeptiert", + name: "DSGVO", 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, show_in_overview: false }, @@ -302,11 +304,15 @@ case Membership.get_settings() do ArgumentError -> Map.has_key?(vis, k) end end + merged = 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) 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) if map_size(updates) > 0 do @@ -332,9 +338,7 @@ IO.puts( " - Fee types: 5 (Standard, Ermäßigt, Unterstützer, Fördermitglied, Probemitgliedschaft)" ) -IO.puts( - " - Custom fields: 6 (Geburtsdatum shown in overview; others hidden by default)" -) +IO.puts(" - Custom fields: 6 (Geburtsdatum shown in overview; others hidden by default)") IO.puts(" - Roles: 5 (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin)") IO.puts(" - Default fee type: Standard (120€ yearly)") diff --git a/priv/repo/seeds_dev.exs b/priv/repo/seeds_dev.exs index 5b3de9f..72474a3 100644 --- a/priv/repo/seeds_dev.exs +++ b/priv/repo/seeds_dev.exs @@ -431,15 +431,16 @@ end) 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 -# 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 = Enum.map(1..16, fn n -> email = "mitglied#{n}@example.de" # Vary birth dates and values per index base_date = Date.add(~D[1970-01-01], 365 * (n + 10) + rem(n * 31, 365)) + values = [ {"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]}}, {"SEPA-Mandat", %{"_union_type" => "boolean", "_union_value" => rem(n, 3) != 0}}, {"Rechnungs-E-Mail", @@ -448,10 +449,12 @@ custom_value_assignments = %{ "_union_type" => "string", "_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_count = rem(n, 3) {email, Enum.take(values, 6 - drop_count)} @@ -502,19 +505,36 @@ case Membership.get_settings() do Membership.update_settings(settings, %{ join_form_enabled: true, 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 + _ -> :ok end # Membership applications (join requests) for UI testing: 4 submitted, 1 with extra form_data 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: "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 @@ -532,8 +552,15 @@ for config <- join_request_configs do end 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(" - Custom field values: ~80% filled (16 members, 4–6 fields each)") IO.puts(" - Join form enabled; 4 membership applications (join requests) for UI testing") diff --git a/priv/resource_snapshots/repo/custom_fields/20260603000204.json b/priv/resource_snapshots/repo/custom_fields/20260603000204.json new file mode 100644 index 0000000..aa0b0ed --- /dev/null +++ b/priv/resource_snapshots/repo/custom_fields/20260603000204.json @@ -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" +} \ No newline at end of file diff --git a/test/membership/custom_field_validation_test.exs b/test/membership/custom_field_validation_test.exs index e642d82..9b1faf5 100644 --- a/test/membership/custom_field_validation_test.exs +++ b/test/membership/custom_field_validation_test.exs @@ -159,6 +159,61 @@ defmodule Mv.Membership.CustomFieldValidationTest do 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 test "rejects duplicate names", %{actor: actor} do assert {:ok, _} = diff --git a/test/mv_web/helpers/join_description_renderer_test.exs b/test/mv_web/helpers/join_description_renderer_test.exs new file mode 100644 index 0000000..39a3351 --- /dev/null +++ b/test/mv_web/helpers/join_description_renderer_test.exs @@ -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(" + 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(Datenschutzerklärung" + 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("") + + refute result =~ "