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 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 defp maybe_reveal_reviews(assignment_id, true) do
Repo.update_all(from(r in Review, where: r.assignment_id == ^assignment_id), Repo.update_all(from(r in Review, where: r.assignment_id == ^assignment_id),
set: [revealed_at: DateTime.utc_now(:second)] set: [revealed_at: DateTime.utc_now(:second)]

View File

@ -3,17 +3,64 @@ defmodule WhoNeedHelpWeb.FormParams do
@live_view_unused_prefix "_unused_" @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) def drop_unused_structured_fields(%{"structured_data" => data} = params)
when is_map(data) do when is_map(data) do
data = data =
Map.reject(data, fn {key, _value} -> Map.reject(data, fn {key, _value} -> unused_key?(key) end)
key
|> to_string()
|> String.starts_with?(@live_view_unused_prefix)
end)
Map.put(params, "structured_data", data) Map.put(params, "structured_data", data)
end end
def drop_unused_structured_fields(params), do: params 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 end

View File

@ -13,11 +13,19 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
|> assign(:categories, Catalog.list_categories(:activity)) |> assign(:categories, Catalog.list_categories(:activity))
|> assign(:selected_category, nil) |> assign(:selected_category, nil)
|> assign(:structured_fields, []) |> assign(:structured_fields, [])
|> assign(:structured_touched_keys, MapSet.new())
|> assign(:structured_submit_attempted, false)
|> assign(:form, to_form(Activities.change_activity(%Activity{})))} |> assign(:form, to_form(Activities.change_activity(%Activity{})))}
end end
@impl true @impl true
def handle_event("validate", %{"activity" => params}, socket) do 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) params = FormParams.drop_unused_structured_fields(params)
changeset = changeset =
@ -28,6 +36,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
{:noreply, {:noreply,
socket socket
|> assign(:form, to_form(changeset)) |> assign(:form, to_form(changeset))
|> assign(:structured_touched_keys, touched_keys)
|> assign_category(params)} |> assign_category(params)}
end end
@ -45,6 +54,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
{:noreply, {:noreply,
socket socket
|> assign(:form, to_form(changeset)) |> assign(:form, to_form(changeset))
|> assign(:structured_submit_attempted, true)
|> assign_category(params)} |> assign_category(params)}
{:error, reason} -> {:error, reason} ->
@ -101,6 +111,26 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
end end
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), defp error_message(:account_not_eligible),
do: gettext("Confirm your email and ensure your account is active before publishing.") do: gettext("Confirm your email and ensure your account is active before publishing.")
@ -214,7 +244,19 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
</select> </select>
</label> </label>
</div> </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} {message}
</p> </p>
</div> </div>

View File

@ -27,12 +27,20 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|> assign(:categories, Catalog.list_categories()) |> assign(:categories, Catalog.list_categories())
|> assign(:selected_category, nil) |> assign(:selected_category, nil)
|> assign(:structured_fields, []) |> assign(:structured_fields, [])
|> assign(:structured_touched_keys, MapSet.new())
|> assign(:structured_submit_attempted, false)
|> assign(:submission_errors, []) |> assign(:submission_errors, [])
|> assign(:form, to_form(changeset))} |> assign(:form, to_form(changeset))}
end end
@impl true @impl true
def handle_event("validate", %{"help_request" => params}, socket) do 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) params = FormParams.drop_unused_structured_fields(params)
changeset = changeset =
@ -43,6 +51,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
{:noreply, {:noreply,
socket socket
|> assign(:form, to_form(changeset)) |> assign(:form, to_form(changeset))
|> assign(:structured_touched_keys, touched_keys)
|> maybe_refresh_submission_errors(changeset) |> maybe_refresh_submission_errors(changeset)
|> assign_category(params)} |> assign_category(params)}
end end
@ -62,6 +71,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
{:noreply, {:noreply,
socket socket
|> assign(:form, to_form(changeset)) |> assign(:form, to_form(changeset))
|> assign(:structured_submit_attempted, true)
|> assign(:submission_errors, submission_errors(changeset)) |> assign(:submission_errors, submission_errors(changeset))
|> assign_category(params)} |> assign_category(params)}
else else
@ -170,6 +180,26 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
end end
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(nil, _locale), do: nil
defp category_description(category, locale), defp category_description(category, locale),
@ -323,7 +353,19 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
</select> </select>
</label> </label>
</div> </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} {message}
</p> </p>
</div> </div>

View File

@ -67,6 +67,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
{:ok, request} -> {:ok, request} ->
previous_status = socket.assigns.request.status previous_status = socket.assigns.request.status
previous_assignment = socket.assigns.assignment previous_assignment = socket.assigns.assignment
reviews_were_revealed = socket.assigns.reviews_revealed
socket = socket =
socket socket
@ -77,8 +78,10 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
previous_assignment, previous_assignment,
request.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} -> {:error, :not_found} ->
{:noreply, {:noreply,
@ -316,11 +319,16 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
def handle_event("review", %{"review" => params}, socket) do def handle_event("review", %{"review" => params}, socket) do
case Trust.submit_review(socket.assigns.current_scope, socket.assigns.assignment, params) do case Trust.submit_review(socket.assigns.current_scope, socket.assigns.assignment, params) do
{:ok, _} -> {: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, {:noreply,
put_action_flash( put_action_flash(
socket, socket,
:info, :info,
gettext("Review saved. It appears after both participants review.") message
)} )}
{:error, reason} -> {:error, reason} ->
@ -613,6 +621,19 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
), ),
do: socket 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(socket, true), do: socket
defp restore_private_interaction(%{assigns: %{assignment: nil}} = socket, false), do: socket defp restore_private_interaction(%{assigns: %{assignment: nil}} = socket, false), do: socket
@ -685,6 +706,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
:review_submitted, :review_submitted,
Trust.review_submitted?(socket.assigns.current_scope, assignment) Trust.review_submitted?(socket.assigns.current_scope, assignment)
) )
|> assign(:reviews_revealed, Trust.reviews_revealed?(assignment))
|> assign(:other_user_id, other_user_id) |> assign(:other_user_id, other_user_id)
|> assign( |> assign(
:blocked_by_current, :blocked_by_current,
@ -1217,7 +1239,9 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
{gettext("Your review is revealed only after both participants submit.")} {gettext("Your review is revealed only after both participants submit.")}
</p> </p>
<p :if={@review_submitted} class="mt-4 text-sm text-base-content/70"> <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> </p>
<.form <.form
:if={!@review_submitted} :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 =~ "Medicine pickup status"
assert html =~ "Pickup and deliver a legal medicine" assert html =~ "Pickup and deliver a legal medicine"
assert html =~ "help_request[structured_data][pickup_status]" assert html =~ "help_request[structured_data][pickup_status]"
refute html =~ "Medicine pickup status is required."
refute html =~ "pickup_status is required"
html = html =
render_change(view, "validate", %{ 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" 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 end
test "category descriptions use the current Ukrainian locale", %{conn: conn} do test "category descriptions use the current Ukrainian locale", %{conn: conn} do
@ -385,6 +398,16 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
}) })
assert html =~ "Отримати й доставити дозволені ліки" assert html =~ "Отримати й доставити дозволені ліки"
html =
render_change(view, "validate", %{
"help_request" => %{
"category_id" => category.id,
"structured_data" => %{"pickup_status" => ""}
}
})
assert html =~ "Поле «Статус отримання ліків» обов’язкове."
end end
test "new request form renders hierarchical roadside fields and safety copy", %{conn: conn} do 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" 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 end
test "organizer approves an activity participant and group chat updates live", %{ test "organizer approves an activity participant and group chat updates live", %{
@ -1227,6 +1261,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|> render_submit() |> render_submit()
refute has_element?(requester_view, "form[phx-submit='review']") 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']") assert has_element?(helper_view, "form[phx-submit='review']")
helper_view helper_view
@ -1234,6 +1270,10 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|> render_submit() |> render_submit()
refute has_element?(helper_view, "form[phx-submit='review']") 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(requester_view) =~ "rating 4.0"
assert render(helper_view) =~ "rating 4.0" assert render(helper_view) =~ "rating 4.0"
assert render(requester_view) =~ ">5.0<" assert render(requester_view) =~ ">5.0<"