fix(ux): clarify structured fields and review reveal

This commit is contained in:
SimpleTest 2026-07-24 11:04:14 +03:00
parent 7cdf5ab4eb
commit ac354f8073
10 changed files with 5229 additions and 2267 deletions

View File

@ -118,6 +118,15 @@ defmodule WhoNeedHelp.Trust do
def review_submitted?(_scope, _assignment), do: false
def reviews_revealed?(%Assignment{id: assignment_id}) do
Repo.exists?(
from review in Review,
where: review.assignment_id == ^assignment_id and not is_nil(review.revealed_at)
)
end
def reviews_revealed?(_assignment), do: false
defp maybe_reveal_reviews(assignment_id, true) do
Repo.update_all(from(r in Review, where: r.assignment_id == ^assignment_id),
set: [revealed_at: DateTime.utc_now(:second)]

View File

@ -3,17 +3,64 @@ defmodule WhoNeedHelpWeb.FormParams do
@live_view_unused_prefix "_unused_"
def touched_structured_keys(%{"structured_data" => data}) when is_map(data) do
data
|> Map.keys()
|> Enum.reject(&unused_key?/1)
|> Enum.reject(fn key ->
Map.has_key?(data, @live_view_unused_prefix <> to_string(key))
end)
|> MapSet.new(&to_string/1)
end
def touched_structured_keys(_params), do: MapSet.new()
def drop_unused_structured_fields(%{"structured_data" => data} = params)
when is_map(data) do
data =
Map.reject(data, fn {key, _value} ->
key
|> to_string()
|> String.starts_with?(@live_view_unused_prefix)
end)
Map.reject(data, fn {key, _value} -> unused_key?(key) end)
Map.put(params, "structured_data", data)
end
def drop_unused_structured_fields(params), do: params
def visible_structured_errors(errors, fields, touched_keys, submitted?, locale)
when is_list(errors) and is_list(fields) do
errors
|> Enum.flat_map(fn {message, _opts} ->
case error_field(message, fields) do
nil ->
if submitted?, do: [{:invalid, nil}], else: []
field ->
key = to_string(field["key"])
if submitted? or MapSet.member?(touched_keys, key) do
[{error_kind(message, key), field_label(field, locale)}]
else
[]
end
end
end)
|> Enum.uniq()
end
defp unused_key?(key),
do: key |> to_string() |> String.starts_with?(@live_view_unused_prefix)
defp error_field(message, fields) do
Enum.find(fields, fn field ->
String.starts_with?(message, "#{field["key"]} ")
end)
end
defp error_kind(message, key) do
if message == "#{key} is required", do: :required, else: :invalid
end
defp field_label(field, locale) do
labels = field["label"] || %{}
labels[to_string(locale)] || labels["en"] || field["key"]
end
end

View File

@ -13,11 +13,19 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|> assign(:categories, Catalog.list_categories(:activity))
|> assign(:selected_category, nil)
|> assign(:structured_fields, [])
|> assign(:structured_touched_keys, MapSet.new())
|> assign(:structured_submit_attempted, false)
|> assign(:form, to_form(Activities.change_activity(%Activity{})))}
end
@impl true
def handle_event("validate", %{"activity" => params}, socket) do
touched_keys =
MapSet.union(
socket.assigns.structured_touched_keys,
FormParams.touched_structured_keys(params)
)
params = FormParams.drop_unused_structured_fields(params)
changeset =
@ -28,6 +36,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
{:noreply,
socket
|> assign(:form, to_form(changeset))
|> assign(:structured_touched_keys, touched_keys)
|> assign_category(params)}
end
@ -45,6 +54,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
{:noreply,
socket
|> assign(:form, to_form(changeset))
|> assign(:structured_submit_attempted, true)
|> assign_category(params)}
{:error, reason} ->
@ -101,6 +111,26 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
end
end
defp structured_error_messages(form, fields, touched_keys, submitted?, locale) do
form[:structured_data].errors
|> FormParams.visible_structured_errors(
fields,
touched_keys,
submitted?,
locale
)
|> Enum.map(fn
{:required, label} ->
gettext("%{field} is required.", field: label)
{:invalid, nil} ->
gettext("Check the category details and try again.")
{:invalid, label} ->
gettext("Choose a valid value for %{field}.", field: label)
end)
end
defp error_message(:account_not_eligible),
do: gettext("Confirm your email and ensure your account is active before publishing.")
@ -214,7 +244,19 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
</select>
</label>
</div>
<p :for={{message, _opts} <- @form[:structured_data].errors} class="text-sm text-error">
<p
:for={
message <-
structured_error_messages(
@form,
@structured_fields,
@structured_touched_keys,
@structured_submit_attempted,
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
)
}
class="text-sm text-error"
>
{message}
</p>
</div>

View File

@ -27,12 +27,20 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|> assign(:categories, Catalog.list_categories())
|> assign(:selected_category, nil)
|> assign(:structured_fields, [])
|> assign(:structured_touched_keys, MapSet.new())
|> assign(:structured_submit_attempted, false)
|> assign(:submission_errors, [])
|> assign(:form, to_form(changeset))}
end
@impl true
def handle_event("validate", %{"help_request" => params}, socket) do
touched_keys =
MapSet.union(
socket.assigns.structured_touched_keys,
FormParams.touched_structured_keys(params)
)
params = FormParams.drop_unused_structured_fields(params)
changeset =
@ -43,6 +51,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
{:noreply,
socket
|> assign(:form, to_form(changeset))
|> assign(:structured_touched_keys, touched_keys)
|> maybe_refresh_submission_errors(changeset)
|> assign_category(params)}
end
@ -62,6 +71,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
{:noreply,
socket
|> assign(:form, to_form(changeset))
|> assign(:structured_submit_attempted, true)
|> assign(:submission_errors, submission_errors(changeset))
|> assign_category(params)}
else
@ -170,6 +180,26 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
end
end
defp structured_error_messages(form, fields, touched_keys, submitted?, locale) do
form[:structured_data].errors
|> FormParams.visible_structured_errors(
fields,
touched_keys,
submitted?,
locale
)
|> Enum.map(fn
{:required, label} ->
gettext("%{field} is required.", field: label)
{:invalid, nil} ->
gettext("Check the category details and try again.")
{:invalid, label} ->
gettext("Choose a valid value for %{field}.", field: label)
end)
end
defp category_description(nil, _locale), do: nil
defp category_description(category, locale),
@ -323,7 +353,19 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
</select>
</label>
</div>
<p :for={{message, _opts} <- @form[:structured_data].errors} class="text-sm text-error">
<p
:for={
message <-
structured_error_messages(
@form,
@structured_fields,
@structured_touched_keys,
@structured_submit_attempted,
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
)
}
class="text-sm text-error"
>
{message}
</p>
</div>

View File

@ -67,6 +67,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
{:ok, request} ->
previous_status = socket.assigns.request.status
previous_assignment = socket.assigns.assignment
reviews_were_revealed = socket.assigns.reviews_revealed
socket =
socket
@ -77,8 +78,10 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
previous_assignment,
request.assignment
)
|> sync_tracking_after_request_update(request)
|> maybe_put_reviews_revealed_flash(reviews_were_revealed)
{:noreply, sync_tracking_after_request_update(socket, request)}
{:noreply, socket}
{:error, :not_found} ->
{:noreply,
@ -316,11 +319,16 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
def handle_event("review", %{"review" => params}, socket) do
case Trust.submit_review(socket.assigns.current_scope, socket.assigns.assignment, params) do
{:ok, _} ->
message =
if Trust.reviews_revealed?(socket.assigns.assignment),
do: gettext("Both reviews are now visible on participant profiles."),
else: gettext("Review saved. It appears after both participants review.")
{:noreply,
put_action_flash(
socket,
:info,
gettext("Review saved. It appears after both participants review.")
message
)}
{:error, reason} ->
@ -613,6 +621,19 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
),
do: socket
defp maybe_put_reviews_revealed_flash(
%{assigns: %{reviews_revealed: true, review_submitted: true}} = socket,
false
) do
put_action_flash(
socket,
:info,
gettext("Both reviews are now visible on participant profiles.")
)
end
defp maybe_put_reviews_revealed_flash(socket, _reviews_were_revealed), do: socket
defp restore_private_interaction(socket, true), do: socket
defp restore_private_interaction(%{assigns: %{assignment: nil}} = socket, false), do: socket
@ -685,6 +706,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
:review_submitted,
Trust.review_submitted?(socket.assigns.current_scope, assignment)
)
|> assign(:reviews_revealed, Trust.reviews_revealed?(assignment))
|> assign(:other_user_id, other_user_id)
|> assign(
:blocked_by_current,
@ -1217,7 +1239,9 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
{gettext("Your review is revealed only after both participants submit.")}
</p>
<p :if={@review_submitted} class="mt-4 text-sm text-base-content/70">
{gettext("Review saved. It appears after both participants review.")}
{if @reviews_revealed,
do: gettext("Both reviews are now visible on participant profiles."),
else: gettext("Review saved. It appears after both participants review.")}
</p>
<.form
:if={!@review_submitted}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -358,6 +358,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "Medicine pickup status"
assert html =~ "Pickup and deliver a legal medicine"
assert html =~ "help_request[structured_data][pickup_status]"
refute html =~ "Medicine pickup status is required."
refute html =~ "pickup_status is required"
html =
render_change(view, "validate", %{
@ -370,8 +372,19 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
}
})
assert html =~ "pickup_status is required"
refute html =~ "Medicine pickup status is required."
refute html =~ "_unused_pickup_status is not allowed"
html =
render_change(view, "validate", %{
"help_request" => %{
"category_id" => category.id,
"structured_data" => %{"pickup_status" => ""}
}
})
assert html =~ "Medicine pickup status is required."
refute html =~ "pickup_status is required"
end
test "category descriptions use the current Ukrainian locale", %{conn: conn} do
@ -385,6 +398,16 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
})
assert html =~ "Отримати й доставити дозволені ліки"
html =
render_change(view, "validate", %{
"help_request" => %{
"category_id" => category.id,
"structured_data" => %{"pickup_status" => ""}
}
})
assert html =~ "Поле «Статус отримання ліків» обов’язкове."
end
test "new request form renders hierarchical roadside fields and safety copy", %{conn: conn} do
@ -540,8 +563,19 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
}
})
assert html =~ "route_plan is required"
refute html =~ "Route plan is required."
refute html =~ "_unused_route_plan is not allowed"
html =
render_change(view, "validate", %{
"activity" => %{
"category_id" => hiking.id,
"structured_data" => %{"route_plan" => ""}
}
})
assert html =~ "Public route summary is required."
refute html =~ "route_plan is required"
end
test "organizer approves an activity participant and group chat updates live", %{
@ -1227,6 +1261,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|> render_submit()
refute has_element?(requester_view, "form[phx-submit='review']")
assert render(requester_view) =~ "Review saved. It appears after both participants review."
refute render(requester_view) =~ "Both reviews are now visible on participant profiles."
assert has_element?(helper_view, "form[phx-submit='review']")
helper_view
@ -1234,6 +1270,10 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|> render_submit()
refute has_element?(helper_view, "form[phx-submit='review']")
assert render(requester_view) =~ "Both reviews are now visible on participant profiles."
assert render(helper_view) =~ "Both reviews are now visible on participant profiles."
refute render(requester_view) =~ "Review saved. It appears after both participants review."
refute render(helper_view) =~ "Review saved. It appears after both participants review."
assert render(requester_view) =~ "rating 4.0"
assert render(helper_view) =~ "rating 4.0"
assert render(requester_view) =~ ">5.0<"