diff --git a/.drone.yml b/.drone.yml index e6770df..81d046f 100644 --- a/.drone.yml +++ b/.drone.yml @@ -273,7 +273,7 @@ environment: steps: - name: renovate - image: renovate/renovate:43.26 + image: renovate/renovate:43.25 environment: RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_TOKEN: diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index 411e95d..ab4ad60 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -10,7 +10,7 @@ defmodule Mv.Membership.CustomField do ## Attributes - `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday") - `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`) - `description` - Optional human-readable description - `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 @@ -28,7 +28,6 @@ defmodule Mv.Membership.CustomField do ## Constraints - Name must be unique across all custom fields - Name maximum length: 100 characters - - `value_type` cannot be changed after creation (immutable) - Deleting a custom field will cascade delete all associated custom field values ## Calculations @@ -60,7 +59,7 @@ defmodule Mv.Membership.CustomField do end actions do - defaults [:read] + defaults [:read, :update] default_accept [:name, :value_type, :description, :required, :show_in_overview] create :create do @@ -69,19 +68,6 @@ defmodule Mv.Membership.CustomField do validate string_length(:slug, min: 1) end - update :update do - accept [:name, :description, :required, :show_in_overview] - require_atomic? false - - validate fn changeset, _context -> - if Ash.Changeset.changing_attribute?(changeset, :value_type) do - {:error, field: :value_type, message: "cannot be changed after creation"} - else - :ok - end - end - end - destroy :destroy_with_values do primary? true end diff --git a/lib/mv_web/live/components/sort_header_component.ex b/lib/mv_web/live/components/sort_header_component.ex index d548efa..3817d90 100644 --- a/lib/mv_web/live/components/sort_header_component.ex +++ b/lib/mv_web/live/components/sort_header_component.ex @@ -26,6 +26,7 @@ defmodule MvWeb.Components.SortHeaderComponent do class="btn btn-ghost select-none" phx-click="sort" phx-value-field={@field} + phx-target={@myself} data-testid={@field} > {@label} @@ -42,6 +43,12 @@ defmodule MvWeb.Components.SortHeaderComponent do """ end + @impl true + def handle_event("sort", %{"field" => field_str}, socket) do + send(self(), {:sort, field_str}) + {:noreply, socket} + end + # ------------------------------------------------- # Hilfsfunktionen für ARIA Attribute & Icon SVG # ------------------------------------------------- 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 f89f767..b809a1a 100644 --- a/lib/mv_web/live/custom_field_live/form_component.ex +++ b/lib/mv_web/live/custom_field_live/form_component.ex @@ -5,7 +5,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do ## Features - Create new custom field definitions - Edit existing custom fields - - Select value type from supported types (only on create; immutable after creation) + - Select value type from supported types - Set required flag - Real-time validation @@ -44,50 +44,15 @@ defmodule MvWeb.CustomFieldLive.FormComponent do > <.input field={@form[:name]} type="text" label={gettext("Name")} /> - <%= if @custom_field do %> - <%!-- Show value_type as read-only input when editing (matches Member Field pattern) --%> -
-
- -
-
- <% else %> - <%!-- Show value_type as select when creating --%> - <.input - field={@form[:value_type]} - type="select" - label={gettext("Value type")} - options={ - Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[ - :one_of - ] - |> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end) - } - /> - <% end %> - + <.input + field={@form[:value_type]} + type="select" + label={gettext("Value type")} + options={ + Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of] + |> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end) + } + /> <.input field={@form[:description]} type="text" label={gettext("Description")} /> <.input field={@form[:required]} type="checkbox" label={gettext("Required")} /> <.input @@ -120,16 +85,8 @@ defmodule MvWeb.CustomFieldLive.FormComponent do @impl true def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do - # Remove value_type from params when editing (it's immutable after creation) - cleaned_params = - if socket.assigns[:custom_field] do - Map.delete(custom_field_params, "value_type") - else - custom_field_params - end - {:noreply, - assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, cleaned_params))} + assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))} end @impl true @@ -137,15 +94,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do # Actor must be passed from parent (IndexComponent); component socket has no current_user actor = socket.assigns[:actor] - # Remove value_type from params when editing (it's immutable after creation) - cleaned_params = - if socket.assigns[:custom_field] do - Map.delete(custom_field_params, "value_type") - else - custom_field_params - end - - case MvWeb.LiveHelpers.submit_form(socket.assigns.form, cleaned_params, actor) do + case MvWeb.LiveHelpers.submit_form(socket.assigns.form, custom_field_params, actor) do {:ok, custom_field} -> action = case socket.assigns.form.source.type do diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index d391cd2..59ee8f9 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -68,15 +68,18 @@ defmodule MvWeb.MemberLive.Index do # This is appropriate for initialization errors that should be visible to the user. actor = current_actor(socket) + custom_fields_visible = + Mv.Membership.CustomField + |> Ash.Query.filter(expr(show_in_overview == true)) + |> Ash.Query.sort(name: :asc) + |> Ash.read!(actor: actor) + + # Load ALL custom fields for the dropdown (to show all available fields) all_custom_fields = Mv.Membership.CustomField |> Ash.Query.sort(name: :asc) |> Ash.read!(actor: actor) - custom_fields_visible = - all_custom_fields - |> Enum.filter(& &1.show_in_overview) - # Load boolean custom fields (filtered and sorted from all_custom_fields) boolean_custom_fields = all_custom_fields @@ -160,7 +163,6 @@ defmodule MvWeb.MemberLive.Index do - `"delete"` - Removes a member from the database - `"select_member"` - Toggles individual member selection - `"select_all"` - Toggles selection of all visible members - - `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL """ @impl true def handle_event("delete", %{"id" => id}, socket) do @@ -303,46 +305,6 @@ defmodule MvWeb.MemberLive.Index do end end - @impl true - def handle_event("sort", %{"field" => field_str}, socket) do - # Handle both atom and string field names (for custom fields) - field = - try do - String.to_existing_atom(field_str) - rescue - ArgumentError -> field_str - end - - {new_field, new_order} = determine_new_sort(field, socket) - old_field = socket.assigns.sort_field - - socket = - socket - |> assign(:sort_field, new_field) - |> assign(:sort_order, new_order) - |> update_sort_components(old_field, new_field, new_order) - |> load_members() - |> update_selection_assigns() - - # URL sync - push_patch happens synchronously in the event handler - query_params = - build_query_params( - socket.assigns.query, - export_sort_field(socket.assigns.sort_field), - export_sort_order(socket.assigns.sort_order), - socket.assigns.cycle_status_filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters - ) - |> maybe_add_field_selection( - socket.assigns[:user_field_selection], - socket.assigns[:fields_in_url?] || false - ) - - {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} - end - # Helper to format errors for display defp format_error(%Ash.Error.Invalid{errors: errors}) do error_messages = @@ -367,10 +329,50 @@ defmodule MvWeb.MemberLive.Index do Handles messages from child components. ## Supported messages: + - `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL - `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL - `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent - `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent """ + @impl true + def handle_info({:sort, field_str}, socket) do + # Handle both atom and string field names (for custom fields) + field = + try do + String.to_existing_atom(field_str) + rescue + ArgumentError -> field_str + end + + {new_field, new_order} = determine_new_sort(field, socket) + old_field = socket.assigns.sort_field + + socket = + socket + |> assign(:sort_field, new_field) + |> assign(:sort_order, new_order) + |> update_sort_components(old_field, new_field, new_order) + |> load_members() + |> update_selection_assigns() + + # URL sync + query_params = + build_query_params( + socket.assigns.query, + export_sort_field(socket.assigns.sort_field), + export_sort_order(socket.assigns.sort_order), + socket.assigns.cycle_status_filter, + socket.assigns[:group_filters], + socket.assigns.show_current_cycle, + socket.assigns.boolean_custom_field_filters + ) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) + + {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} + end @impl true def handle_info({:search_changed, q}, socket) do diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 0d661cf..6dbb732 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2604,11 +2604,6 @@ msgstr "PDF" msgid "Import" msgstr "Import" -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Value type cannot be changed after creation" -msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden." - #~ #: lib/mv_web/live/import_export_live.ex #~ #, elixir-autogen, elixir-format, fuzzy #~ msgid "Export Members (CSV)" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 0aef1b3..df282f3 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2604,8 +2604,3 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Import" msgstr "" - -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Value type cannot be changed after creation" -msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 371a028..56f897d 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2605,11 +2605,6 @@ msgstr "" msgid "Import" msgstr "" -#: lib/mv_web/live/custom_field_live/form_component.ex -#, elixir-autogen, elixir-format -msgid "Value type cannot be changed after creation" -msgstr "" - #~ #: lib/mv_web/live/import_export_live.ex #~ #, elixir-autogen, elixir-format, fuzzy #~ msgid "Export Members (CSV)" diff --git a/test/membership/custom_field_validation_test.exs b/test/membership/custom_field_validation_test.exs index e642d82..d0711ad 100644 --- a/test/membership/custom_field_validation_test.exs +++ b/test/membership/custom_field_validation_test.exs @@ -8,7 +8,6 @@ defmodule Mv.Membership.CustomFieldValidationTest do - Description length validation (max 500 characters) - Description trimming - Required vs optional fields - - Value type immutability (cannot be changed after creation) """ use Mv.DataCase, async: true @@ -208,101 +207,4 @@ defmodule Mv.Membership.CustomFieldValidationTest do assert [%{field: :value_type}] = changeset.errors end end - - describe "value_type immutability" do - test "rejects attempt to change value_type after creation", %{actor: actor} do - # Create custom field with value_type :string - {:ok, custom_field} = - CustomField - |> Ash.Changeset.for_create(:create, %{ - name: "test_field", - value_type: :string - }) - |> Ash.create(actor: actor) - - original_value_type = custom_field.value_type - assert original_value_type == :string - - # Attempt to update value_type to :integer - assert {:error, %Ash.Error.Invalid{} = error} = - custom_field - |> Ash.Changeset.for_update(:update, %{ - value_type: :integer - }) - |> Ash.update(actor: actor) - - # Verify error message contains expected text - error_message = Exception.message(error) - assert error_message =~ "cannot be changed" or error_message =~ "value_type" - - # Reload and verify value_type remained unchanged - reloaded = Ash.get!(CustomField, custom_field.id, actor: actor) - assert reloaded.value_type == original_value_type - assert reloaded.value_type == :string - end - - test "allows updating other fields while value_type remains unchanged", %{actor: actor} do - # Create custom field with value_type :string - {:ok, custom_field} = - CustomField - |> Ash.Changeset.for_create(:create, %{ - name: "test_field", - value_type: :string, - description: "Original description" - }) - |> Ash.create(actor: actor) - - original_value_type = custom_field.value_type - assert original_value_type == :string - - # Update other fields (name, description) without touching value_type - {:ok, updated_custom_field} = - custom_field - |> Ash.Changeset.for_update(:update, %{ - name: "updated_name", - description: "Updated description" - }) - |> Ash.update(actor: actor) - - # Verify value_type remained unchanged - assert updated_custom_field.value_type == original_value_type - assert updated_custom_field.value_type == :string - # Verify other fields were updated - assert updated_custom_field.name == "updated_name" - assert updated_custom_field.description == "Updated description" - end - - test "rejects value_type change even when other fields are updated", %{actor: actor} do - # Create custom field with value_type :boolean - {:ok, custom_field} = - CustomField - |> Ash.Changeset.for_create(:create, %{ - name: "test_field", - value_type: :boolean - }) - |> Ash.create(actor: actor) - - original_value_type = custom_field.value_type - assert original_value_type == :boolean - - # Attempt to update both name and value_type - assert {:error, %Ash.Error.Invalid{} = error} = - custom_field - |> Ash.Changeset.for_update(:update, %{ - name: "updated_name", - value_type: :date - }) - |> Ash.update(actor: actor) - - # Verify error message - error_message = Exception.message(error) - assert error_message =~ "cannot be changed" or error_message =~ "value_type" - - # Reload and verify value_type remained unchanged, but name was not updated either - reloaded = Ash.get!(CustomField, custom_field.id, actor: actor) - assert reloaded.value_type == original_value_type - assert reloaded.value_type == :boolean - assert reloaded.name == "test_field" - end - end end diff --git a/test/mv_web/components/sort_header_component_test.exs b/test/mv_web/components/sort_header_component_test.exs index bdde4ae..6d23ab4 100644 --- a/test/mv_web/components/sort_header_component_test.exs +++ b/test/mv_web/components/sort_header_component_test.exs @@ -223,7 +223,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end describe "component behavior" do - test "clicking triggers sort event on parent LiveView", %{conn: conn} do + test "clicking sends sort message to parent", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") @@ -232,7 +232,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do |> element("button[phx-value-field='first_name']") |> render_click() - # The component triggers a "sort" event on the parent LiveView + # The component should send a message to the parent LiveView # This is tested indirectly through the URL change in integration tests end diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 53a2815..4f36795 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -1,5 +1,5 @@ defmodule MvWeb.MemberLive.IndexTest do - use MvWeb.ConnCase, async: false + use MvWeb.ConnCase, async: true import Phoenix.LiveViewTest require Ash.Query