feat(custom-field): add join_description attribute for GDPR join-form labels

This commit is contained in:
Simon 2026-06-03 12:01:41 +02:00
parent d51dcb1ac3
commit b6c2cf58b1
4 changed files with 242 additions and 3 deletions

View file

@ -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

View file

@ -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

View 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"
}

View file

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