Improve UX of join requests and fix minor bugs (#492)
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:
Simon 2026-05-06 14:34:42 +02:00 committed by simon
parent bfa33dcae2
commit 2bb01bd201
14 changed files with 781 additions and 135 deletions

View file

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