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/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/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, _} =