Improve UX of join requests and fix minor bugs (#492)
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
## Description of the implemented changes The changes were: - [x] Bugfixing - [x] New Feature - [ ] Breaking Change - [ ] Refactoring This PR improves the join-request flow and presentation quality, fixes several data-display issues in join/join-request screens, and adds a usability improvement in global settings (directly opening the join link). It also includes dependency updates and changelog maintenance. ## What has been changed? - Join form (`JoinLive`) now renders inputs based on actual field types (including checkbox/date/number/email behavior instead of generic text-only handling). - Join form custom-field labels are resolved from configured custom fields (fallback remains safe if lookup fails). - Join-request details page (`JoinRequestLive.Show`) now: - resolves and shows custom field names instead of raw IDs, - formats boolean-like values (`on/true/1`, `off/false/0`) as localized `Yes/No`, - formats ISO date strings for better readability, - keeps legacy field handling while improving output consistency. - Join-request detail layout was improved semantically and visually (`dl/dt/dd` structure for label/value rows). - Global settings page now includes an **Open** button for the join URL (`target="_blank"`, `rel="noopener noreferrer"`, ARIA label). - Added/updated tests around: - join field type rendering, - custom field labels in join-request views, - related auth/global-settings behavior. - Updated translations (`default.pot`, `en`, `de`) for new UI strings. - Updated dependencies/tooling (`mix.lock`, `mix.exs`, CI/renovate-related updates). - Updated `CHANGELOG.md` entries for unreleased changes. ## Definition of Done ### Code Quality - [x] No new technical depths - [x] Linting passed - [x] Documentation is added were needed ### Accessibility - [x] New elements are properly defined with html-tags - [x] Colour contrast follows WCAG criteria - [x] Aria labels are added when needed - [x] Everything is accessible by keyboard - [x] Tab-Order is comprehensible - [x] All interactive elements have a visible focus ### Testing - [x] Tests for new code are written - [ ] All tests pass - [ ] axe-core dev tools show no critical or major issues ## Additional Notes - Reviewer focus areas: - `lib/mv_web/live/join_live.ex`: input type derivation and custom field lookup strategy (`authorize?: false` read path used intentionally for field metadata). - `lib/mv_web/live/join_request_live/show.ex`: value-formatting logic (especially backward compatibility for legacy `form_data` payloads). - `lib/mv_web/live/global_settings_live.ex`: external-link behavior and accessibility attributes. - The branch also contains dependency update commits; please review lockfile and CI-related changes separately from functional join/join-request changes. Reviewed-on: #492 Co-authored-by: Simon <s.thiessen@local-it.org> Co-committed-by: Simon <s.thiessen@local-it.org>
This commit is contained in:
parent
bfa33dcae2
commit
2bb01bd201
14 changed files with 781 additions and 135 deletions
|
|
@ -64,6 +64,21 @@ defmodule MvWeb.GlobalSettingsLiveTest do
|
|||
|
||||
assert html =~ "must be present"
|
||||
end
|
||||
|
||||
test "shows open button for join page URL in same row as copy", %{conn: conn} do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
{:ok, _} = Membership.update_settings(settings, %{join_form_enabled: true})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
assert has_element?(view, "#copy-join-url-btn")
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"a[href][target=\"_blank\"][rel=\"noopener noreferrer\"]",
|
||||
"Open"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe "SMTP / E-Mail section" do
|
||||
|
|
|
|||
|
|
@ -12,10 +12,9 @@ defmodule MvWeb.JoinLiveTest do
|
|||
# async: false → shared sandbox; all processes (including LiveView) share the DB connection.
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
import Ecto.Query
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.Repo
|
||||
alias Mv.Membership.JoinRequest
|
||||
|
||||
describe "GET /join" do
|
||||
@tag role: :unauthenticated
|
||||
|
|
@ -55,11 +54,12 @@ defmodule MvWeb.JoinLiveTest do
|
|||
})
|
||||
|> render_submit()
|
||||
|
||||
# Anti-enumeration delay is applied in LiveView via send_after (100–300 ms); wait for success UI.
|
||||
Process.sleep(400)
|
||||
assert_eventually(fn -> count_join_requests() == count_before + 1 end)
|
||||
|
||||
assert_eventually(fn ->
|
||||
view |> element("[data-testid='join-success-message']") |> has_element?()
|
||||
end)
|
||||
|
||||
assert count_join_requests() == count_before + 1
|
||||
assert view |> element("[data-testid='join-success-message']") |> has_element?()
|
||||
assert render(view) =~ "saved your details"
|
||||
assert render(view) =~ "click the link"
|
||||
end
|
||||
|
|
@ -135,6 +135,181 @@ defmodule MvWeb.JoinLiveTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "join field labels" do
|
||||
@tag role: :unauthenticated
|
||||
test "renders custom field name as label for custom field IDs", %{conn: conn} do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, custom_field} =
|
||||
Membership.create_custom_field(
|
||||
%{
|
||||
name: "Preferred Pronouns",
|
||||
value_type: :string
|
||||
},
|
||||
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 has_element?(
|
||||
view,
|
||||
"label[for='join-field-#{custom_field.id}'] .label-text",
|
||||
custom_field.name
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe "join field input types" do
|
||||
@tag role: :unauthenticated
|
||||
test "renders boolean custom field as checkbox input", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
{:ok, boolean_field} =
|
||||
Membership.create_custom_field(
|
||||
%{
|
||||
name: "Subscribe to newsletter",
|
||||
value_type: :boolean
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _} =
|
||||
Membership.update_settings(settings, %{
|
||||
join_form_enabled: true,
|
||||
join_form_field_ids: ["email", boolean_field.id],
|
||||
join_form_field_required: %{"email" => true, boolean_field.id => false}
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/join")
|
||||
|
||||
assert has_element?(view, "#join-form")
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"input#join-field-#{boolean_field.id}[name='#{boolean_field.id}']"
|
||||
)
|
||||
|
||||
assert has_element?(view, "input#join-field-#{boolean_field.id}[type='checkbox']")
|
||||
refute has_element?(view, "input#join-field-#{boolean_field.id}[type='text']")
|
||||
end
|
||||
|
||||
@tag role: :unauthenticated
|
||||
test "renders typed custom fields with matching HTML input types", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
{:ok, integer_field} =
|
||||
Membership.create_custom_field(%{name: "Lucky number", value_type: :integer},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, date_field} =
|
||||
Membership.create_custom_field(%{name: "Birth date", value_type: :date},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, email_field} =
|
||||
Membership.create_custom_field(%{name: "Secondary email", value_type: :email},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _} =
|
||||
Membership.update_settings(settings, %{
|
||||
join_form_enabled: true,
|
||||
join_form_field_ids: ["email", integer_field.id, date_field.id, email_field.id],
|
||||
join_form_field_required: %{
|
||||
"email" => true,
|
||||
integer_field.id => false,
|
||||
date_field.id => false,
|
||||
email_field.id => false
|
||||
}
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/join")
|
||||
|
||||
assert has_element?(view, "input#join-field-#{integer_field.id}[type='number']")
|
||||
assert has_element?(view, "input#join-field-#{date_field.id}[type='date']")
|
||||
assert has_element?(view, "input#join-field-#{email_field.id}[type='email']")
|
||||
end
|
||||
|
||||
@tag role: :unauthenticated
|
||||
test "renders standard date member fields with date input type", %{conn: conn} do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
{:ok, _} =
|
||||
Membership.update_settings(settings, %{
|
||||
join_form_enabled: true,
|
||||
join_form_field_ids: ["email", "join_date"],
|
||||
join_form_field_required: %{"email" => true, "join_date" => false}
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/join")
|
||||
|
||||
assert has_element?(view, "input#join-field-join_date[type='date']")
|
||||
refute has_element?(view, "input#join-field-join_date[type='text']")
|
||||
end
|
||||
end
|
||||
|
||||
describe "submit join form with typed custom fields" do
|
||||
setup do
|
||||
reset_rate_limiter()
|
||||
:ok
|
||||
end
|
||||
|
||||
@tag role: :unauthenticated
|
||||
test "persists checked boolean custom field and ignores non-allowlisted field", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
{:ok, boolean_field} =
|
||||
Membership.create_custom_field(
|
||||
%{
|
||||
name: "Receive announcements",
|
||||
value_type: :boolean
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _} =
|
||||
Membership.update_settings(settings, %{
|
||||
join_form_enabled: true,
|
||||
join_form_field_ids: ["email", boolean_field.id],
|
||||
join_form_field_required: %{"email" => true, boolean_field.id => false}
|
||||
})
|
||||
|
||||
count_before = count_join_requests()
|
||||
{:ok, view, _html} = live(conn, "/join")
|
||||
|
||||
view
|
||||
|> element("#join-form")
|
||||
|> render_submit(%{
|
||||
"email" => "typed#{System.unique_integer([:positive])}@example.com",
|
||||
"website" => "",
|
||||
boolean_field.id => "on",
|
||||
"not_allowlisted" => "should-not-be-persisted"
|
||||
})
|
||||
|
||||
assert_eventually(fn -> count_join_requests() == count_before + 1 end)
|
||||
|
||||
assert_eventually(fn ->
|
||||
view |> element("[data-testid='join-success-message']") |> has_element?()
|
||||
end)
|
||||
|
||||
form_data = latest_join_request_form_data()
|
||||
assert Map.get(form_data, boolean_field.id) == "on"
|
||||
refute Map.has_key?(form_data, "not_allowlisted")
|
||||
end
|
||||
end
|
||||
|
||||
defp enable_join_form(enabled) do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
{:ok, _} = Membership.update_settings(settings, %{join_form_enabled: enabled})
|
||||
|
|
@ -154,7 +329,40 @@ defmodule MvWeb.JoinLiveTest do
|
|||
end
|
||||
|
||||
defp count_join_requests do
|
||||
Repo.one(from j in "join_requests", select: count(j.id)) || 0
|
||||
case Ash.count(JoinRequest, domain: Membership, authorize?: false) do
|
||||
{:ok, count} -> count
|
||||
_ -> 0
|
||||
end
|
||||
end
|
||||
|
||||
defp latest_join_request_form_data do
|
||||
query =
|
||||
JoinRequest
|
||||
|> Ash.Query.sort(inserted_at: :desc)
|
||||
|> Ash.Query.limit(1)
|
||||
|
||||
case Ash.read(query, domain: Membership, authorize?: false) do
|
||||
{:ok, [request]} -> request.form_data || %{}
|
||||
_ -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
defp assert_eventually(fun, timeout_ms \\ 1500) when is_function(fun, 0) do
|
||||
deadline = System.monotonic_time(:millisecond) + timeout_ms
|
||||
do_assert_eventually(fun, deadline)
|
||||
end
|
||||
|
||||
defp do_assert_eventually(fun, deadline) do
|
||||
if fun.() do
|
||||
true
|
||||
else
|
||||
if System.monotonic_time(:millisecond) < deadline do
|
||||
Process.sleep(25)
|
||||
do_assert_eventually(fun, deadline)
|
||||
else
|
||||
assert fun.()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp reset_rate_limiter do
|
||||
|
|
|
|||
124
test/mv_web/live/join_request_live/show_test.exs
Normal file
124
test/mv_web/live/join_request_live/show_test.exs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
defmodule MvWeb.JoinRequestLive.ShowTest do
|
||||
@moduledoc """
|
||||
Tests for join request detail view label rendering.
|
||||
|
||||
Focus: applicant data labels for custom fields should use custom field names,
|
||||
not raw UUIDs.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Fixtures
|
||||
alias Mv.Membership
|
||||
alias MvWeb.Helpers.DateFormatter
|
||||
|
||||
setup do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
saved = %{
|
||||
join_form_enabled: settings.join_form_enabled,
|
||||
join_form_field_ids: settings.join_form_field_ids,
|
||||
join_form_field_required: settings.join_form_field_required
|
||||
}
|
||||
|
||||
on_exit(fn ->
|
||||
{:ok, current_settings} = Membership.get_settings()
|
||||
|
||||
Membership.update_settings(current_settings, %{
|
||||
join_form_enabled: saved.join_form_enabled,
|
||||
join_form_field_ids: saved.join_form_field_ids || [],
|
||||
join_form_field_required: saved.join_form_field_required || %{}
|
||||
})
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "custom field labels in applicant data" do
|
||||
@tag role: :normal_user
|
||||
test "renders custom field name instead of custom field UUID", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
{:ok, custom_field} =
|
||||
Membership.create_custom_field(
|
||||
%{
|
||||
name: "Emergency contact",
|
||||
value_type: :string
|
||||
},
|
||||
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}
|
||||
})
|
||||
|
||||
join_request =
|
||||
Fixtures.submitted_join_request_fixture(%{
|
||||
form_data: %{custom_field.id => "Alice Example"}
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/join_requests/#{join_request.id}")
|
||||
|
||||
assert has_element?(view, "dt", "#{custom_field.name}:")
|
||||
assert has_element?(view, "dd", "Alice Example")
|
||||
refute has_element?(view, "dt", "#{custom_field.id}:")
|
||||
end
|
||||
|
||||
@tag role: :normal_user
|
||||
test "formats boolean/date values and renders status in aligned row", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
{:ok, boolean_field} =
|
||||
Membership.create_custom_field(
|
||||
%{
|
||||
name: "Privacy accepted",
|
||||
value_type: :boolean
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, date_field} =
|
||||
Membership.create_custom_field(
|
||||
%{
|
||||
name: "Birth date",
|
||||
value_type: :date
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _} =
|
||||
Membership.update_settings(settings, %{
|
||||
join_form_enabled: true,
|
||||
join_form_field_ids: ["email", boolean_field.id, date_field.id],
|
||||
join_form_field_required: %{
|
||||
"email" => true,
|
||||
boolean_field.id => false,
|
||||
date_field.id => false
|
||||
}
|
||||
})
|
||||
|
||||
join_request =
|
||||
Fixtures.submitted_join_request_fixture(%{
|
||||
form_data: %{
|
||||
boolean_field.id => "on",
|
||||
date_field.id => "2000-01-12"
|
||||
}
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/join_requests/#{join_request.id}")
|
||||
|
||||
assert has_element?(view, "dt", "Privacy accepted:")
|
||||
assert has_element?(view, "dd", "Yes")
|
||||
assert has_element?(view, "dt", "Birth date:")
|
||||
assert has_element?(view, "dd", DateFormatter.format_date(~D[2000-01-12]))
|
||||
assert has_element?(view, "dt", "Status:")
|
||||
assert has_element?(view, "dd", "Submitted")
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue