Improve trust profiles and account case history
This commit is contained in:
parent
f97b00bda8
commit
c634c136c9
|
|
@ -63,6 +63,25 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
select: struct(identity, ^@public_social_identity_fields)
|
select: struct(identity, ^@public_social_identity_fields)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Loads the public profile projection for an account that has not been suspended.
|
||||||
|
|
||||||
|
The returned struct deliberately contains only `public_user_query/1` fields and
|
||||||
|
public social identities. It never loads email, authentication, role, or
|
||||||
|
moderation-note fields.
|
||||||
|
"""
|
||||||
|
def get_public_user(id) do
|
||||||
|
with {:ok, id} <- cast_id(id),
|
||||||
|
%User{} = user <-
|
||||||
|
public_user_query(social_identities: true)
|
||||||
|
|> where([user], user.id == ^id and user.moderation_status != :suspended)
|
||||||
|
|> Repo.one() do
|
||||||
|
{:ok, user}
|
||||||
|
else
|
||||||
|
_invalid_missing_or_suspended -> {:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets a user by email.
|
Gets a user by email.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,14 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def list_proposal_parents(mode) when mode in [:help, :activity] do
|
||||||
|
Category
|
||||||
|
|> where([category], category.mode == ^mode)
|
||||||
|
|> order_by([category], asc: category.sort_order, asc: category.slug)
|
||||||
|
|> preload(:parent)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
def get_category!(id), do: Repo.get!(Category, id)
|
def get_category!(id), do: Repo.get!(Category, id)
|
||||||
|
|
||||||
def category_path(%Category{parent: %Category{} = parent} = category, locale) do
|
def category_path(%Category{parent: %Category{} = parent} = category, locale) do
|
||||||
|
|
@ -38,6 +46,14 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
end
|
end
|
||||||
|
|
||||||
def paginate_proposals(options \\ []) do
|
def paginate_proposals(options \\ []) do
|
||||||
|
paginate_proposals_for_user(nil, options)
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginate_proposals_for(%Scope{user: %{id: user_id}}, options \\ []) do
|
||||||
|
paginate_proposals_for_user(user_id, options)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp paginate_proposals_for_user(user_id, options) do
|
||||||
limit = Pagination.limit(options)
|
limit = Pagination.limit(options)
|
||||||
cursor = Pagination.cursor(options)
|
cursor = Pagination.cursor(options)
|
||||||
public_user = Accounts.public_user_query()
|
public_user = Accounts.public_user_query()
|
||||||
|
|
@ -48,6 +64,7 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
|> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id)
|
|> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id)
|
||||||
|> limit(^(limit + 1))
|
|> limit(^(limit + 1))
|
||||||
|> with_vote_count()
|
|> with_vote_count()
|
||||||
|
|> with_current_vote(user_id)
|
||||||
|> preload([proposal], proposer: ^public_user, parent: [])
|
|> preload([proposal], proposer: ^public_user, parent: [])
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||||||
|
|
@ -201,8 +218,8 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
category_attrs
|
category_attrs
|
||||||
|> Map.new(fn {key, value} -> {to_string(key), value} end)
|
|> Map.new(fn {key, value} -> {to_string(key), value} end)
|
||||||
|> normalize_descriptions()
|
|> normalize_descriptions()
|
||||||
|> Map.put_new("parent_id", proposal.parent_id)
|
|> Map.put("parent_id", proposal.parent_id)
|
||||||
|> Map.put_new("mode", to_string(proposal.mode))
|
|> Map.put("mode", to_string(proposal.mode))
|
||||||
|
|
||||||
with {:ok, category} <-
|
with {:ok, category} <-
|
||||||
%Category{} |> Category.changeset(category_attrs) |> Repo.insert(),
|
%Category{} |> Category.changeset(category_attrs) |> Repo.insert(),
|
||||||
|
|
@ -242,12 +259,10 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
end
|
end
|
||||||
|
|
||||||
def merge_proposal(%Scope{user: moderator}, proposal_id, category_id, note) do
|
def merge_proposal(%Scope{user: moderator}, proposal_id, category_id, note) do
|
||||||
with {:ok, category_id} <- cast_id(category_id),
|
with {:ok, category_id} <- cast_id(category_id) do
|
||||||
%Category{} <- Repo.get(Category, category_id) do
|
|
||||||
moderate_proposal(moderator, proposal_id, :merged, category_id, note)
|
moderate_proposal(moderator, proposal_id, :merged, category_id, note)
|
||||||
else
|
else
|
||||||
{:error, :not_found} = error -> error
|
{:error, :not_found} = error -> error
|
||||||
nil -> {:error, :not_found}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -257,10 +272,24 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
Repo.transact(fn ->
|
Repo.transact(fn ->
|
||||||
proposal = locked_proposal(proposal_id)
|
proposal = locked_proposal(proposal_id)
|
||||||
|
|
||||||
|
merge_target =
|
||||||
|
if status == :merged do
|
||||||
|
Category
|
||||||
|
|> where([category], category.id == ^merged_into_id)
|
||||||
|
|> lock("FOR SHARE")
|
||||||
|
|> Repo.one()
|
||||||
|
end
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
is_nil(proposal) ->
|
is_nil(proposal) ->
|
||||||
{:error, :not_found}
|
{:error, :not_found}
|
||||||
|
|
||||||
|
status == :merged and is_nil(merge_target) ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
status == :merged and merge_target.mode != proposal.mode ->
|
||||||
|
{:error, :mode_mismatch}
|
||||||
|
|
||||||
proposal.status == :open ->
|
proposal.status == :open ->
|
||||||
with {:ok, proposal} <-
|
with {:ok, proposal} <-
|
||||||
proposal
|
proposal
|
||||||
|
|
@ -889,6 +918,21 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp with_current_vote(query, nil) do
|
||||||
|
select_merge(query, [proposal], %{voted_by_current_user: false})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp with_current_vote(query, user_id) do
|
||||||
|
query
|
||||||
|
|> join(:left, [proposal], vote in CategoryVote,
|
||||||
|
on: vote.proposal_id == proposal.id and vote.user_id == ^user_id,
|
||||||
|
as: :current_vote
|
||||||
|
)
|
||||||
|
|> select_merge([current_vote: vote], %{
|
||||||
|
voted_by_current_user: not is_nil(vote.id)
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
defp before_proposal(query, nil), do: query
|
defp before_proposal(query, nil), do: query
|
||||||
|
|
||||||
defp before_proposal(query, {inserted_at, id}) do
|
defp before_proposal(query, {inserted_at, id}) do
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do
|
||||||
field :reviewed_at, :utc_datetime
|
field :reviewed_at, :utc_datetime
|
||||||
field :moderation_note, :string
|
field :moderation_note, :string
|
||||||
field :vote_count, :integer, virtual: true, default: 0
|
field :vote_count, :integer, virtual: true, default: 0
|
||||||
|
field :voted_by_current_user, :boolean, virtual: true, default: false
|
||||||
has_many :votes, WhoNeedHelp.Catalog.CategoryVote, foreign_key: :proposal_id
|
has_many :votes, WhoNeedHelp.Catalog.CategoryVote, foreign_key: :proposal_id
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime)
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,15 @@ defmodule WhoNeedHelp.ContentRemoval do
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get_for_user(%Scope{user: %User{id: user_id}}, id) do
|
||||||
|
with {:ok, id} <- Ecto.UUID.cast(id),
|
||||||
|
%Notice{} = notice <- Repo.get_by(Notice, id: id, requester_id: user_id) do
|
||||||
|
{:ok, notice}
|
||||||
|
else
|
||||||
|
_ -> {:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def get_by_access_token(id, token) when is_binary(token) do
|
def get_by_access_token(id, token) when is_binary(token) do
|
||||||
with {:ok, id} <- Ecto.UUID.cast(id),
|
with {:ok, id} <- Ecto.UUID.cast(id),
|
||||||
{:ok, ^id} <-
|
{:ok, ^id} <-
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,11 @@ defmodule WhoNeedHelpWeb.ContentRemovalController do
|
||||||
alias WhoNeedHelp.ContentRemoval
|
alias WhoNeedHelp.ContentRemoval
|
||||||
alias WhoNeedHelp.ContentRemoval.Notice
|
alias WhoNeedHelp.ContentRemoval.Notice
|
||||||
|
|
||||||
|
def index(conn, _params) do
|
||||||
|
notices = ContentRemoval.list_for_user(conn.assigns.current_scope)
|
||||||
|
render(conn, :index, notices: notices)
|
||||||
|
end
|
||||||
|
|
||||||
def new(conn, params) do
|
def new(conn, params) do
|
||||||
attrs =
|
attrs =
|
||||||
case internal_location(params["location"]) do
|
case internal_location(params["location"]) do
|
||||||
|
|
@ -27,15 +32,25 @@ defmodule WhoNeedHelpWeb.ContentRemovalController do
|
||||||
def create_take_it_down(conn, _params),
|
def create_take_it_down(conn, _params),
|
||||||
do: send_resp(conn, :bad_request, "Bad Request")
|
do: send_resp(conn, :bad_request, "Bad Request")
|
||||||
|
|
||||||
def show(conn, %{"id" => id, "token" => token}) do
|
def show(conn, %{"id" => id} = params) do
|
||||||
case ContentRemoval.get_by_access_token(id, token) do
|
result =
|
||||||
|
case conn.assigns.current_scope do
|
||||||
|
nil ->
|
||||||
|
ContentRemoval.get_by_access_token(id, params["token"])
|
||||||
|
|
||||||
|
scope ->
|
||||||
|
case ContentRemoval.get_for_user(scope, id) do
|
||||||
|
{:ok, notice} -> {:ok, notice}
|
||||||
|
_ -> ContentRemoval.get_by_access_token(id, params["token"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
case result do
|
||||||
{:ok, notice} -> render(conn, :show, notice: notice)
|
{:ok, notice} -> render(conn, :show, notice: notice)
|
||||||
{:error, :not_found} -> send_resp(conn, :not_found, "Not found")
|
{:error, :not_found} -> send_resp(conn, :not_found, "Not found")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def show(conn, _params), do: send_resp(conn, :not_found, "Not found")
|
|
||||||
|
|
||||||
defp create_notice(conn, regime, params) do
|
defp create_notice(conn, regime, params) do
|
||||||
case ContentRemoval.create_notice(conn.assigns.current_scope, regime, params) do
|
case ContentRemoval.create_notice(conn.assigns.current_scope, regime, params) do
|
||||||
{:ok, notice} ->
|
{:ok, notice} ->
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<Layouts.app flash={@flash} current_scope={@current_scope} page_width={:reading}>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold text-error">{gettext("CONTENT REMOVAL")}</div>
|
||||||
|
<h1 class="mt-1 text-4xl font-black">{gettext("Your notices")}</h1>
|
||||||
|
</div>
|
||||||
|
<.link navigate={~p"/legal/content-removal"} class="btn btn-error">
|
||||||
|
{gettext("New notice")}
|
||||||
|
</.link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-3 text-sm text-base-content/60">
|
||||||
|
{gettext(
|
||||||
|
"Only notices submitted while signed in appear here. Notices submitted without an account remain accessible through their private email link."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 space-y-3">
|
||||||
|
<.link
|
||||||
|
:for={notice <- @notices}
|
||||||
|
navigate={~p"/legal/content-removal/#{notice.id}"}
|
||||||
|
class="block rounded-2xl border border-base-300 p-5 transition hover:border-error"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="badge badge-outline">{notice.reference}</span>
|
||||||
|
<span class={[
|
||||||
|
"badge",
|
||||||
|
notice.status == :urgent_review && "badge-error",
|
||||||
|
notice.status != :urgent_review && "badge-primary"
|
||||||
|
]}>
|
||||||
|
{status_label(notice.status)}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-base-content/55">{category_label(notice.category)}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 line-clamp-2 text-sm text-base-content/70">{notice.explanation}</p>
|
||||||
|
<time class="mt-2 block text-xs text-base-content/50">{notice.inserted_at}</time>
|
||||||
|
</.link>
|
||||||
|
<p :if={@notices == []} class="rounded-2xl bg-base-200 p-6 text-base-content/60">
|
||||||
|
{gettext("You have not submitted any content-removal notices while signed in.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layouts.app>
|
||||||
|
|
@ -12,6 +12,13 @@
|
||||||
"Provide exact Who Need Help URLs. Do not upload or paste intimate imagery, identity documents, passwords, access codes, or unnecessary medical information."
|
"Provide exact Who Need Help URLs. Do not upload or paste intimate imagery, identity documents, passwords, access codes, or unnecessary medical information."
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<.link
|
||||||
|
:if={@current_scope}
|
||||||
|
navigate={~p"/legal/content-removal/requests"}
|
||||||
|
class="btn btn-outline btn-sm mt-5"
|
||||||
|
>
|
||||||
|
{gettext("View my notices")}
|
||||||
|
</.link>
|
||||||
|
|
||||||
<.form for={@form} action={~p"/legal/content-removal"} class="mt-8 space-y-4">
|
<.form for={@form} action={~p"/legal/content-removal"} class="mt-8 space-y-4">
|
||||||
<.input
|
<.input
|
||||||
|
|
@ -39,6 +46,9 @@
|
||||||
value={@contact_email || @form[:contact_email].value}
|
value={@contact_email || @form[:contact_email].value}
|
||||||
readonly={not is_nil(@contact_email)}
|
readonly={not is_nil(@contact_email)}
|
||||||
/>
|
/>
|
||||||
|
<p :if={@contact_email} class="text-xs text-base-content/55">
|
||||||
|
{gettext("Status updates go to the email address of your signed-in account.")}
|
||||||
|
</p>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:relationship]}
|
field={@form[:relationship]}
|
||||||
type="select"
|
type="select"
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,17 @@
|
||||||
"A private status link has been sent when a contact email was provided and delivery is configured. Opening that link verifies the contact address. Keep the reference for follow-up."
|
"A private status link has been sent when a contact email was provided and delivery is configured. Opening that link verifies the contact address. Keep the reference for follow-up."
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<.link navigate={~p"/legal/content-removal"} class="btn btn-primary mt-7">
|
<div class="mt-7 flex flex-wrap justify-center gap-3">
|
||||||
|
<.link
|
||||||
|
:if={@current_scope}
|
||||||
|
navigate={~p"/legal/content-removal/requests"}
|
||||||
|
class="btn btn-primary"
|
||||||
|
>
|
||||||
|
{gettext("View my notices")}
|
||||||
|
</.link>
|
||||||
|
<.link navigate={~p"/legal/content-removal"} class="btn btn-outline">
|
||||||
{gettext("Back to removal form")}
|
{gettext("Back to removal form")}
|
||||||
</.link>
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope} page_width={:compact}>
|
<Layouts.app flash={@flash} current_scope={@current_scope} page_width={:compact}>
|
||||||
<div>
|
<div>
|
||||||
|
<.link
|
||||||
|
:if={@current_scope && @notice.requester_id == @current_scope.user.id}
|
||||||
|
navigate={~p"/legal/content-removal/requests"}
|
||||||
|
class="link text-sm"
|
||||||
|
>
|
||||||
|
← {gettext("Back to my notices")}
|
||||||
|
</.link>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="badge badge-outline">{@notice.reference}</span>
|
<span class="badge badge-outline">{@notice.reference}</span>
|
||||||
<span class={[
|
<span class={[
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@
|
||||||
"Do not upload the image or video. Provide only the exact Who Need Help URL where it appears and enough text to identify it safely."
|
"Do not upload the image or video. Provide only the exact Who Need Help URL where it appears and enough text to identify it safely."
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<.link
|
||||||
|
:if={@current_scope}
|
||||||
|
navigate={~p"/legal/content-removal/requests"}
|
||||||
|
class="btn btn-outline btn-sm mt-5"
|
||||||
|
>
|
||||||
|
{gettext("View my notices")}
|
||||||
|
</.link>
|
||||||
|
|
||||||
<.form for={@form} action={~p"/legal/take-it-down"} class="mt-8 space-y-4">
|
<.form for={@form} action={~p"/legal/take-it-down"} class="mt-8 space-y-4">
|
||||||
<.input
|
<.input
|
||||||
|
|
@ -36,6 +43,9 @@
|
||||||
readonly={not is_nil(@contact_email)}
|
readonly={not is_nil(@contact_email)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<p :if={@contact_email} class="text-xs text-base-content/55">
|
||||||
|
{gettext("Status updates go to the email address of your signed-in account.")}
|
||||||
|
</p>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:relationship]}
|
field={@form[:relationship]}
|
||||||
type="select"
|
type="select"
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@
|
||||||
readonly={not is_nil(@contact_email)}
|
readonly={not is_nil(@contact_email)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<p :if={@contact_email} class="text-xs text-base-content/55">
|
||||||
|
{gettext("This request is linked to your signed-in account and its email address.")}
|
||||||
|
</p>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:details]}
|
field={@form[:details]}
|
||||||
type="textarea"
|
type="textarea"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-5 flex flex-wrap gap-2">
|
<div class="mt-5 flex flex-wrap gap-2">
|
||||||
|
<.link :if={@current_scope} navigate={~p"/support/requests"} class="btn btn-outline btn-sm">
|
||||||
|
{gettext("View my requests")}
|
||||||
|
</.link>
|
||||||
<.link navigate={~p"/legal/content-removal"} class="btn btn-outline btn-sm">
|
<.link navigate={~p"/legal/content-removal"} class="btn btn-outline btn-sm">
|
||||||
{gettext("Report content")}
|
{gettext("Report content")}
|
||||||
</.link>
|
</.link>
|
||||||
|
|
@ -45,6 +48,11 @@
|
||||||
readonly={not is_nil(@contact_email)}
|
readonly={not is_nil(@contact_email)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<p :if={@contact_email} class="text-xs text-base-content/55">
|
||||||
|
{gettext(
|
||||||
|
"This request is linked to your signed-in account. Replies go to its email address."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
<.input field={@form[:subject]} label={gettext("Subject")} required />
|
<.input field={@form[:subject]} label={gettext("Subject")} required />
|
||||||
<.input
|
<.input
|
||||||
field={@form[:details]}
|
field={@form[:details]}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@
|
||||||
reference: @reference
|
reference: @reference
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<.link navigate={~p"/support"} class="btn btn-primary mt-7">{gettext("Back to support")}</.link>
|
<div class="mt-7 flex flex-wrap justify-center gap-3">
|
||||||
|
<.link :if={@current_scope} navigate={~p"/support/requests"} class="btn btn-primary">
|
||||||
|
{gettext("View my requests")}
|
||||||
|
</.link>
|
||||||
|
<.link navigate={~p"/support"} class="btn btn-outline">{gettext("Back to support")}</.link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,9 @@
|
||||||
<.link navigate={~p"/support/requests"} class="btn btn-outline">
|
<.link navigate={~p"/support/requests"} class="btn btn-outline">
|
||||||
{gettext("View support requests")}
|
{gettext("View support requests")}
|
||||||
</.link>
|
</.link>
|
||||||
|
<.link navigate={~p"/legal/content-removal/requests"} class="btn btn-outline">
|
||||||
|
{gettext("View content-removal notices")}
|
||||||
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -393,7 +393,9 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-xs text-base-content/50">{gettext("Organizer")}</dt>
|
<dt class="text-xs text-base-content/50">{gettext("Organizer")}</dt>
|
||||||
<dd class="font-semibold">
|
<dd class="font-semibold">
|
||||||
|
<.link navigate={~p"/people/#{@activity.creator.id}"} class="link">
|
||||||
{@activity.creator.display_name || gettext("Community member")}
|
{@activity.creator.display_name || gettext("Community member")}
|
||||||
|
</.link>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -469,7 +471,9 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="text-xs font-semibold text-info">
|
<div class="text-xs font-semibold text-info">
|
||||||
|
<.link navigate={~p"/people/#{message.sender.id}"} class="link">
|
||||||
{message.sender.display_name || gettext("Participant")}
|
{message.sender.display_name || gettext("Participant")}
|
||||||
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
<time
|
<time
|
||||||
id={"activity-message-time-#{message.id}"}
|
id={"activity-message-time-#{message.id}"}
|
||||||
|
|
@ -608,7 +612,9 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
class="rounded-xl bg-base-200 p-3"
|
class="rounded-xl bg-base-200 p-3"
|
||||||
>
|
>
|
||||||
<div class="font-semibold">
|
<div class="font-semibold">
|
||||||
|
<.link navigate={~p"/people/#{participant.user.id}"} class="link">
|
||||||
{participant.user.display_name || gettext("Community member")}
|
{participant.user.display_name || gettext("Community member")}
|
||||||
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
:if={participant.user.social_identities != []}
|
:if={participant.user.social_identities != []}
|
||||||
|
|
@ -665,7 +671,9 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
<h2 class="font-bold">{gettext("Approved participants")}</h2>
|
<h2 class="font-bold">{gettext("Approved participants")}</h2>
|
||||||
<ul class="mt-3 space-y-2 text-sm">
|
<ul class="mt-3 space-y-2 text-sm">
|
||||||
<li :for={participant <- approved_participants(@activity)}>
|
<li :for={participant <- approved_participants(@activity)}>
|
||||||
|
<.link navigate={~p"/people/#{participant.user.id}"} class="link">
|
||||||
{participant.user.display_name || gettext("Community member")}
|
{participant.user.display_name || gettext("Community member")}
|
||||||
|
</.link>
|
||||||
<span :if={participant.role == :organizer} class="badge badge-xs ml-1">
|
<span :if={participant.role == :organizer} class="badge badge-xs ml-1">
|
||||||
{gettext("organizer")}
|
{gettext("organizer")}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,22 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("validate", %{"category_proposal" => params}, socket) do
|
||||||
|
mode = proposal_mode(params["mode"])
|
||||||
|
|
||||||
|
form =
|
||||||
|
%CategoryProposal{}
|
||||||
|
|> Catalog.change_proposal(params)
|
||||||
|
|> Map.put(:action, :validate)
|
||||||
|
|> to_form()
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:form, form)
|
||||||
|
|> assign(:proposal_mode, mode)
|
||||||
|
|> assign(:parent_categories, Catalog.list_proposal_parents(mode))}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("vote", %{"id" => id}, socket) do
|
def handle_event("vote", %{"id" => id}, socket) do
|
||||||
case Catalog.vote(socket.assigns.current_scope, id) do
|
case Catalog.vote(socket.assigns.current_scope, id) do
|
||||||
{:ok, _} -> {:noreply, load(socket)}
|
{:ok, _} -> {:noreply, load(socket)}
|
||||||
|
|
@ -34,8 +50,19 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("unvote", %{"id" => id}, socket) do
|
||||||
|
case Catalog.unvote(socket.assigns.current_scope, id) do
|
||||||
|
{:ok, _count} -> {:noreply, load(socket)}
|
||||||
|
{:error, reason} -> {:noreply, put_flash(socket, :error, action_error(reason))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("load-more-proposals", _params, socket) do
|
def handle_event("load-more-proposals", _params, socket) do
|
||||||
page = Catalog.paginate_proposals(after: socket.assigns.proposals_cursor)
|
page =
|
||||||
|
Catalog.paginate_proposals_for(socket.assigns.current_scope,
|
||||||
|
after: socket.assigns.proposals_cursor
|
||||||
|
)
|
||||||
|
|
||||||
existing_ids = MapSet.new(socket.assigns.proposals, & &1.id)
|
existing_ids = MapSet.new(socket.assigns.proposals, & &1.id)
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
@ -56,21 +83,28 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
|
||||||
|
|
||||||
defp action_error(:proposal_closed), do: gettext("This proposal is already closed.")
|
defp action_error(:proposal_closed), do: gettext("This proposal is already closed.")
|
||||||
defp action_error(:not_found), do: gettext("This proposal is no longer available.")
|
defp action_error(:not_found), do: gettext("This proposal is no longer available.")
|
||||||
|
defp action_error(:invalid_parent), do: gettext("Choose a valid parent category.")
|
||||||
defp action_error(%Ecto.Changeset{}), do: gettext("You already voted for this proposal.")
|
defp action_error(%Ecto.Changeset{}), do: gettext("You already voted for this proposal.")
|
||||||
|
|
||||||
defp action_error(_reason), do: gettext("Could not complete the action. Please try again.")
|
defp action_error(_reason), do: gettext("Could not complete the action. Please try again.")
|
||||||
|
|
||||||
defp load(socket) do
|
defp load(socket) do
|
||||||
proposals = Catalog.paginate_proposals()
|
proposals = Catalog.paginate_proposals_for(socket.assigns.current_scope)
|
||||||
|
mode = socket.assigns[:proposal_mode] || :help
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Category proposals"))
|
|> assign(:page_title, gettext("Category proposals"))
|
||||||
|> assign(:proposals, proposals.entries)
|
|> assign(:proposals, proposals.entries)
|
||||||
|> assign(:proposals_cursor, proposals.next_cursor)
|
|> assign(:proposals_cursor, proposals.next_cursor)
|
||||||
|> assign(:categories, Catalog.list_all_categories())
|
|> assign(:proposal_mode, mode)
|
||||||
|> assign(:form, to_form(Catalog.change_proposal(%CategoryProposal{})))
|
|> assign(:parent_categories, Catalog.list_proposal_parents(mode))
|
||||||
|
|> assign(:form, to_form(Catalog.change_proposal(%CategoryProposal{mode: mode})))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp proposal_mode("activity"), do: :activity
|
||||||
|
defp proposal_mode(:activity), do: :activity
|
||||||
|
defp proposal_mode(_mode), do: :help
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
@ -89,6 +123,7 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
|
||||||
<.form
|
<.form
|
||||||
for={@form}
|
for={@form}
|
||||||
id="category-proposal-form"
|
id="category-proposal-form"
|
||||||
|
phx-change="validate"
|
||||||
phx-submit="propose"
|
phx-submit="propose"
|
||||||
class="mt-7 space-y-4 rounded-3xl bg-base-200 p-6"
|
class="mt-7 space-y-4 rounded-3xl bg-base-200 p-6"
|
||||||
>
|
>
|
||||||
|
|
@ -113,8 +148,8 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
|
||||||
prompt={gettext("Top level")}
|
prompt={gettext("Top level")}
|
||||||
options={
|
options={
|
||||||
Enum.map(
|
Enum.map(
|
||||||
@categories,
|
@parent_categories,
|
||||||
&{WhoNeedHelp.Catalog.Category.name(
|
&{WhoNeedHelp.Catalog.category_path(
|
||||||
&1,
|
&1,
|
||||||
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
||||||
), &1.id}
|
), &1.id}
|
||||||
|
|
@ -153,8 +188,25 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
|
||||||
{gettext("by %{name}", name: proposal.proposer.display_name)}
|
{gettext("by %{name}", name: proposal.proposer.display_name)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button phx-click="vote" phx-value-id={proposal.id} class="btn btn-outline btn-sm">
|
<button
|
||||||
▲ {proposal.vote_count}
|
:if={!proposal.voted_by_current_user}
|
||||||
|
id={"proposal-vote-#{proposal.id}"}
|
||||||
|
phx-click="vote"
|
||||||
|
phx-value-id={proposal.id}
|
||||||
|
aria-pressed="false"
|
||||||
|
class="btn btn-outline btn-sm"
|
||||||
|
>
|
||||||
|
{gettext("Vote")} · {proposal.vote_count}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:if={proposal.voted_by_current_user}
|
||||||
|
id={"proposal-unvote-#{proposal.id}"}
|
||||||
|
phx-click="unvote"
|
||||||
|
phx-value-id={proposal.id}
|
||||||
|
aria-pressed="true"
|
||||||
|
class="btn btn-success btn-sm"
|
||||||
|
>
|
||||||
|
✓ {gettext("Voted")} · {proposal.vote_count}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,11 @@ defmodule WhoNeedHelpWeb.LeaderboardLive do
|
||||||
</tr>
|
</tr>
|
||||||
<tr :for={{entry, index} <- Enum.with_index(@leaders, 1)}>
|
<tr :for={{entry, index} <- Enum.with_index(@leaders, 1)}>
|
||||||
<td class="font-black">#{index}</td>
|
<td class="font-black">#{index}</td>
|
||||||
<td class="font-semibold">{entry.user.display_name}</td>
|
<td class="font-semibold">
|
||||||
|
<.link navigate={~p"/people/#{entry.user.id}"} class="link">
|
||||||
|
{entry.user.display_name}
|
||||||
|
</.link>
|
||||||
|
</td>
|
||||||
<td>{entry.location_supported_people}</td>
|
<td>{entry.location_supported_people}</td>
|
||||||
<td>{entry.verified_people}</td>
|
<td>{entry.verified_people}</td>
|
||||||
<td>{entry.unique_people}</td>
|
<td>{entry.unique_people}</td>
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
|
|
||||||
defp error_message(:forbidden), do: gettext("Moderator access is required.")
|
defp error_message(:forbidden), do: gettext("Moderator access is required.")
|
||||||
defp error_message(:proposal_closed), do: gettext("This proposal has already been reviewed.")
|
defp error_message(:proposal_closed), do: gettext("This proposal has already been reviewed.")
|
||||||
|
defp error_message(:mode_mismatch), do: gettext("Choose a category from the same mode.")
|
||||||
|
|
||||||
defp error_message(:cannot_restrict_self),
|
defp error_message(:cannot_restrict_self),
|
||||||
do: gettext("You cannot restrict your own moderator account.")
|
do: gettext("You cannot restrict your own moderator account.")
|
||||||
|
|
@ -545,7 +546,9 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
type="select"
|
type="select"
|
||||||
label={gettext("Merge into")}
|
label={gettext("Merge into")}
|
||||||
options={
|
options={
|
||||||
Enum.map(@categories, &{WhoNeedHelp.Catalog.Category.name(&1, "en"), &1.id})
|
@categories
|
||||||
|
|> Enum.filter(&(&1.mode == proposal.mode))
|
||||||
|
|> Enum.map(&{WhoNeedHelp.Catalog.category_path(&1, "en"), &1.id})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<.input field={merge_form[:note]} placeholder={gettext("Merge note")} />
|
<.input field={merge_form[:note]} placeholder={gettext("Merge note")} />
|
||||||
|
|
|
||||||
|
|
@ -212,7 +212,15 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
{gettext("None revealed yet.")}
|
{gettext("None revealed yet.")}
|
||||||
</p>
|
</p>
|
||||||
<div :for={review <- @reviews} class="mt-3 border-t border-base-300 pt-3 text-sm">
|
<div :for={review <- @reviews} class="mt-3 border-t border-base-300 pt-3 text-sm">
|
||||||
<div class="font-semibold">{"★" |> String.duplicate(review.rating)}</div>
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span class="font-semibold">{"★" |> String.duplicate(review.rating)}</span>
|
||||||
|
<.link
|
||||||
|
navigate={~p"/people/#{review.reviewer.id}"}
|
||||||
|
class="link text-xs text-base-content/60"
|
||||||
|
>
|
||||||
|
{review.reviewer.display_name}
|
||||||
|
</.link>
|
||||||
|
</div>
|
||||||
<div class="mt-1">{review.comment}</div>
|
<div class="mt-1">{review.comment}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
181
lib/who_need_help_web/live/public_profile_live.ex
Normal file
181
lib/who_need_help_web/live/public_profile_live.ex
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
defmodule WhoNeedHelpWeb.PublicProfileLive do
|
||||||
|
use WhoNeedHelpWeb, :live_view
|
||||||
|
|
||||||
|
alias WhoNeedHelp.Accounts
|
||||||
|
alias WhoNeedHelp.Trust
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
|
case Accounts.get_public_user(id) do
|
||||||
|
{:ok, user} ->
|
||||||
|
reviews = Trust.paginate_visible_reviews(user.id)
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, user.display_name)
|
||||||
|
|> assign(:user, user)
|
||||||
|
|> assign(:reputation, Trust.reputation(user.id))
|
||||||
|
|> assign(:reviews, reviews.entries)
|
||||||
|
|> assign(:reviews_cursor, reviews.next_cursor)}
|
||||||
|
|
||||||
|
{:error, :not_found} ->
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, gettext("This public profile is not available."))
|
||||||
|
|> push_navigate(to: ~p"/leaderboard")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("load-more-reviews", _params, socket) do
|
||||||
|
page =
|
||||||
|
Trust.paginate_visible_reviews(socket.assigns.user.id,
|
||||||
|
after: socket.assigns.reviews_cursor
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_ids = MapSet.new(socket.assigns.reviews, & &1.id)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(
|
||||||
|
:reviews,
|
||||||
|
socket.assigns.reviews ++
|
||||||
|
Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id))
|
||||||
|
)
|
||||||
|
|> assign(:reviews_cursor, page.next_cursor)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp provider_label(:github), do: "GitHub"
|
||||||
|
defp provider_label(:google), do: "Google"
|
||||||
|
defp provider_label(:instagram), do: "Instagram"
|
||||||
|
defp provider_label(:facebook), do: "Facebook"
|
||||||
|
defp provider_label(:telegram), do: "Telegram"
|
||||||
|
defp provider_label(:other), do: gettext("Other")
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash} current_scope={@current_scope} page_width={:reading}>
|
||||||
|
<.link navigate={~p"/leaderboard"} class="btn btn-ghost btn-sm">
|
||||||
|
{gettext("← Community helpers")}
|
||||||
|
</.link>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-6 md:grid-cols-[1.15fr_.85fr]">
|
||||||
|
<section class="rounded-3xl border border-base-300 p-6">
|
||||||
|
<div class="text-sm font-semibold text-success">{gettext("COMMUNITY PROFILE")}</div>
|
||||||
|
<h1 class="mt-2 text-4xl font-black tracking-tight">{@user.display_name}</h1>
|
||||||
|
<p :if={@user.bio not in [nil, ""]} class="mt-4 whitespace-pre-wrap text-base-content/70">
|
||||||
|
{@user.bio}
|
||||||
|
</p>
|
||||||
|
<p :if={@user.bio in [nil, ""]} class="mt-4 text-sm text-base-content/55">
|
||||||
|
{gettext("This person has not added a public bio yet.")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-7">
|
||||||
|
<h2 class="text-xl font-bold">{gettext("Public social links")}</h2>
|
||||||
|
<p class="mt-1 text-sm text-base-content/55">
|
||||||
|
{gettext(
|
||||||
|
"A verified badge means the account completed that provider's authorization flow. Manually added links remain unverified."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p :if={@user.social_identities == []} class="mt-4 text-sm text-base-content/55">
|
||||||
|
{gettext("No public social links added.")}
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
|
<a
|
||||||
|
:for={identity <- @user.social_identities}
|
||||||
|
href={identity.profile_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow ugc"
|
||||||
|
class="btn btn-outline btn-sm"
|
||||||
|
>
|
||||||
|
<span>{provider_label(identity.provider)}</span>
|
||||||
|
<span class={[
|
||||||
|
"badge badge-sm",
|
||||||
|
identity.verified_at && "badge-success",
|
||||||
|
is_nil(identity.verified_at) && "badge-warning"
|
||||||
|
]}>
|
||||||
|
{if identity.verified_at, do: gettext("verified"), else: gettext("unverified")}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :if={@user.tip_url not in [nil, ""]} class="mt-7 rounded-2xl bg-base-200 p-5">
|
||||||
|
<h2 class="font-bold">{gettext("Optional thanks")}</h2>
|
||||||
|
<p class="mt-1 text-sm text-base-content/60">
|
||||||
|
{gettext(
|
||||||
|
"This external link is optional and goes directly to the person. Who Need Help does not process or guarantee a payment."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={@user.tip_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow ugc"
|
||||||
|
class="btn btn-outline btn-sm mt-4"
|
||||||
|
>
|
||||||
|
{gettext("Open thank-you link")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="space-y-6">
|
||||||
|
<section class="rounded-3xl bg-success p-6 text-success-content">
|
||||||
|
<h2 class="font-bold">{gettext("Public reputation")}</h2>
|
||||||
|
<div class="mt-5 grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-3xl font-black">{@reputation.completed}</div>
|
||||||
|
<div class="text-xs">{gettext("completed")}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-3xl font-black">{@reputation.unique_people}</div>
|
||||||
|
<div class="text-xs">{gettext("unique people")}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-3xl font-black">{@reputation.verified_handovers}</div>
|
||||||
|
<div class="text-xs">{gettext("verified handovers")}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-3xl font-black">{@reputation.rating || "—"}</div>
|
||||||
|
<div class="text-xs">{gettext("rating")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-3xl border border-base-300 p-6">
|
||||||
|
<h2 class="text-xl font-bold">{gettext("Revealed reviews")}</h2>
|
||||||
|
<p class="mt-1 text-sm text-base-content/55">
|
||||||
|
{gettext("A review becomes public only after both people submit for that match.")}
|
||||||
|
</p>
|
||||||
|
<p :if={@reviews == []} class="mt-4 text-sm text-base-content/55">
|
||||||
|
{gettext("None revealed yet.")}
|
||||||
|
</p>
|
||||||
|
<article :for={review <- @reviews} class="mt-4 border-t border-base-300 pt-4 text-sm">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span class="font-semibold">{"★" |> String.duplicate(review.rating)}</span>
|
||||||
|
<.link
|
||||||
|
navigate={~p"/people/#{review.reviewer.id}"}
|
||||||
|
class="link text-xs text-base-content/60"
|
||||||
|
>
|
||||||
|
{review.reviewer.display_name}
|
||||||
|
</.link>
|
||||||
|
</div>
|
||||||
|
<p :if={review.comment not in [nil, ""]} class="mt-2 whitespace-pre-wrap">
|
||||||
|
{review.comment}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<button
|
||||||
|
:if={@reviews_cursor}
|
||||||
|
type="button"
|
||||||
|
phx-click="load-more-reviews"
|
||||||
|
class="btn btn-outline btn-sm mt-5 w-full"
|
||||||
|
>
|
||||||
|
{gettext("Load more")}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -831,7 +831,12 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-base-content/50">{gettext("Requester:")}</span>
|
<span class="text-base-content/50">{gettext("Requester:")}</span>
|
||||||
|
<.link
|
||||||
|
navigate={~p"/people/#{@request.requester.id}"}
|
||||||
|
class="link font-medium"
|
||||||
|
>
|
||||||
{@request.requester.display_name}
|
{@request.requester.display_name}
|
||||||
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-base-content/50">{gettext("Expires:")}</span>
|
<span class="text-base-content/50">{gettext("Expires:")}</span>
|
||||||
|
|
@ -1067,7 +1072,13 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
<% @participant -> %>
|
<% @participant -> %>
|
||||||
<div class="mt-4 space-y-3">
|
<div class="mt-4 space-y-3">
|
||||||
<div class="rounded-xl bg-white/10 p-3 text-sm">
|
<div class="rounded-xl bg-white/10 p-3 text-sm">
|
||||||
{gettext("Helper: %{name}", name: @assignment.helper.display_name)}
|
{gettext("Helper:")}
|
||||||
|
<.link
|
||||||
|
navigate={~p"/people/#{@assignment.helper.id}"}
|
||||||
|
class="link ml-1 font-medium"
|
||||||
|
>
|
||||||
|
{@assignment.helper.display_name}
|
||||||
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-3 gap-2 rounded-xl bg-white/10 p-3 text-center text-xs">
|
<div class="grid grid-cols-3 gap-2 rounded-xl bg-white/10 p-3 text-center text-xs">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,6 @@ defmodule WhoNeedHelpWeb.Router do
|
||||||
get "/legal/content-removal", ContentRemovalController, :new
|
get "/legal/content-removal", ContentRemovalController, :new
|
||||||
post "/legal/content-removal", ContentRemovalController, :create
|
post "/legal/content-removal", ContentRemovalController, :create
|
||||||
get "/legal/content-removal/received", ContentRemovalController, :received
|
get "/legal/content-removal/received", ContentRemovalController, :received
|
||||||
get "/legal/content-removal/:id", ContentRemovalController, :show
|
|
||||||
get "/legal/take-it-down", ContentRemovalController, :take_it_down
|
get "/legal/take-it-down", ContentRemovalController, :take_it_down
|
||||||
post "/legal/take-it-down", ContentRemovalController, :create_take_it_down
|
post "/legal/take-it-down", ContentRemovalController, :create_take_it_down
|
||||||
end
|
end
|
||||||
|
|
@ -138,6 +137,13 @@ defmodule WhoNeedHelpWeb.Router do
|
||||||
get "/auth/social/:provider", SocialOAuthController, :request
|
get "/auth/social/:provider", SocialOAuthController, :request
|
||||||
get "/auth/social/:provider/callback", SocialOAuthController, :callback
|
get "/auth/social/:provider/callback", SocialOAuthController, :callback
|
||||||
get "/support/requests", SupportController, :index
|
get "/support/requests", SupportController, :index
|
||||||
|
get "/legal/content-removal/requests", ContentRemovalController, :index
|
||||||
|
end
|
||||||
|
|
||||||
|
scope "/", WhoNeedHelpWeb do
|
||||||
|
pipe_through :browser
|
||||||
|
|
||||||
|
get "/legal/content-removal/:id", ContentRemovalController, :show
|
||||||
end
|
end
|
||||||
|
|
||||||
scope "/", WhoNeedHelpWeb do
|
scope "/", WhoNeedHelpWeb do
|
||||||
|
|
@ -153,6 +159,7 @@ defmodule WhoNeedHelpWeb.Router do
|
||||||
live "/activities/:id", ActivityLive.Show, :show
|
live "/activities/:id", ActivityLive.Show, :show
|
||||||
live "/categories/proposals", CategoryProposalLive, :index
|
live "/categories/proposals", CategoryProposalLive, :index
|
||||||
live "/profile", ProfileLive, :edit
|
live "/profile", ProfileLive, :edit
|
||||||
|
live "/people/:id", PublicProfileLive, :show
|
||||||
live "/leaderboard", LeaderboardLive, :index
|
live "/leaderboard", LeaderboardLive, :index
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,56 @@ defmodule WhoNeedHelp.AccountsTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "get_public_user/1" do
|
||||||
|
test "loads only the public projection and public social identities" do
|
||||||
|
user =
|
||||||
|
user_fixture()
|
||||||
|
|> set_password()
|
||||||
|
|> Ecto.Changeset.change(
|
||||||
|
bio: "A short public bio",
|
||||||
|
role: :admin,
|
||||||
|
moderation_note: "private moderator note"
|
||||||
|
)
|
||||||
|
|> Repo.update!()
|
||||||
|
|
||||||
|
{:ok, identity} =
|
||||||
|
Accounts.add_social_identity(user, %{
|
||||||
|
"provider" => "telegram",
|
||||||
|
"profile_url" => "https://t.me/public_profile_test",
|
||||||
|
"handle" => "@public_profile_test"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, public_user} = Accounts.get_public_user(user.id)
|
||||||
|
assert public_user.id == user.id
|
||||||
|
assert public_user.bio == "A short public bio"
|
||||||
|
|
||||||
|
assert [%{id: identity_id, profile_url: "https://t.me/public_profile_test"}] =
|
||||||
|
public_user.social_identities
|
||||||
|
|
||||||
|
assert identity_id == identity.id
|
||||||
|
assert is_nil(public_user.email)
|
||||||
|
assert is_nil(public_user.hashed_password)
|
||||||
|
assert public_user.role == :user
|
||||||
|
assert is_nil(public_user.moderation_note)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not expose invalid, missing, or suspended accounts" do
|
||||||
|
user = user_fixture()
|
||||||
|
|
||||||
|
assert {:error, :not_found} = Accounts.get_public_user("not-a-uuid")
|
||||||
|
|
||||||
|
assert {:error, :not_found} =
|
||||||
|
Accounts.get_public_user("11111111-1111-1111-1111-111111111111")
|
||||||
|
|
||||||
|
{1, nil} =
|
||||||
|
Repo.update_all(from(candidate in User, where: candidate.id == ^user.id),
|
||||||
|
set: [moderation_status: :suspended]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {:error, :not_found} = Accounts.get_public_user(user.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "register_user/1" do
|
describe "register_user/1" do
|
||||||
test "requires email to be set" do
|
test "requires email to be set" do
|
||||||
{:error, changeset} = Accounts.register_user(%{})
|
{:error, changeset} = Accounts.register_user(%{})
|
||||||
|
|
|
||||||
|
|
@ -508,10 +508,30 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
||||||
assert [%{id: listed_id, vote_count: 1}] = Catalog.list_proposals()
|
assert [%{id: listed_id, vote_count: 1}] = Catalog.list_proposals()
|
||||||
assert listed_id == proposal.id
|
assert listed_id == proposal.id
|
||||||
|
|
||||||
|
assert [%{voted_by_current_user: true}] =
|
||||||
|
Catalog.paginate_proposals_for(context.helper_scope).entries
|
||||||
|
|
||||||
|
assert {:ok, 1} = Catalog.unvote(context.helper_scope, proposal.id)
|
||||||
|
|
||||||
|
assert [%{vote_count: 0, voted_by_current_user: false}] =
|
||||||
|
Catalog.paginate_proposals_for(context.helper_scope).entries
|
||||||
|
|
||||||
refute export =~ context.requester.email
|
refute export =~ context.requester.email
|
||||||
refute export =~ context.requester.display_name
|
refute export =~ context.requester.display_name
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "proposal parents include taxonomy containers and stay within their mode", _context do
|
||||||
|
Catalog.seed_defaults()
|
||||||
|
|
||||||
|
help_parents = Catalog.list_proposal_parents(:help)
|
||||||
|
activity_parents = Catalog.list_proposal_parents(:activity)
|
||||||
|
|
||||||
|
assert Enum.any?(help_parents, &(&1.slug == "roadside-help" and &1.active == false))
|
||||||
|
assert Enum.all?(help_parents, &(&1.mode == :help))
|
||||||
|
assert Enum.all?(activity_parents, &(&1.mode == :activity))
|
||||||
|
refute Enum.any?(activity_parents, &(&1.slug == "roadside-help"))
|
||||||
|
end
|
||||||
|
|
||||||
test "default urgent and roadside categories are hierarchical and idempotent", context do
|
test "default urgent and roadside categories are hierarchical and idempotent", context do
|
||||||
first_ids =
|
first_ids =
|
||||||
Catalog.list_categories()
|
Catalog.list_categories()
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,32 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
||||||
assert request.contact_verified_at
|
assert request.contact_verified_at
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "content-removal ownership lookup is scoped to the submitting account" do
|
||||||
|
owner = user_fixture()
|
||||||
|
outsider = user_fixture()
|
||||||
|
|
||||||
|
assert {:ok, notice} =
|
||||||
|
ContentRemoval.create_notice(user_scope_fixture(owner), :general, %{
|
||||||
|
"category" => "privacy_violation",
|
||||||
|
"submitter_name" => "Notice Owner",
|
||||||
|
"relationship" => "self",
|
||||||
|
"content_locations" => "https://example.test/requests/scoped-notice",
|
||||||
|
"explanation" =>
|
||||||
|
"This notice verifies account-scoped access to a removal request.",
|
||||||
|
"electronic_signature" => "Notice Owner",
|
||||||
|
"good_faith" => "true",
|
||||||
|
"accurate_complete" => "true"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, ^notice} = ContentRemoval.get_for_user(user_scope_fixture(owner), notice.id)
|
||||||
|
|
||||||
|
assert {:error, :not_found} =
|
||||||
|
ContentRemoval.get_for_user(user_scope_fixture(outsider), notice.id)
|
||||||
|
|
||||||
|
assert [^notice] = ContentRemoval.list_for_user(user_scope_fixture(owner))
|
||||||
|
assert [] = ContentRemoval.list_for_user(user_scope_fixture(outsider))
|
||||||
|
end
|
||||||
|
|
||||||
defp moderator_scope do
|
defp moderator_scope do
|
||||||
user_fixture()
|
user_fixture()
|
||||||
|> Ecto.Changeset.change(role: :moderator)
|
|> Ecto.Changeset.change(role: :moderator)
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,67 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "category moderation cannot cross help and activity taxonomies", context do
|
||||||
|
moderator =
|
||||||
|
user_fixture(display_name: "Category moderator")
|
||||||
|
|> Ecto.Changeset.change(role: :moderator)
|
||||||
|
|> Repo.update!()
|
||||||
|
|
||||||
|
moderator_scope = user_scope_fixture(moderator)
|
||||||
|
activity_category = Catalog.list_categories(:activity) |> List.first()
|
||||||
|
|
||||||
|
{:ok, activity_proposal} =
|
||||||
|
Catalog.propose(context.requester_scope, %{
|
||||||
|
"proposed_name" => "Board games",
|
||||||
|
"mode" => "activity",
|
||||||
|
"reason" => "A reusable social activity category for public board game meetings."
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:error, :mode_mismatch} =
|
||||||
|
Catalog.merge_proposal(
|
||||||
|
moderator_scope,
|
||||||
|
activity_proposal.id,
|
||||||
|
context.category.id,
|
||||||
|
"Wrong mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {:ok, merged} =
|
||||||
|
Catalog.merge_proposal(
|
||||||
|
moderator_scope,
|
||||||
|
activity_proposal.id,
|
||||||
|
activity_category.id,
|
||||||
|
"Same mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert merged.status == :merged
|
||||||
|
assert merged.merged_into_id == activity_category.id
|
||||||
|
|
||||||
|
roadside_parent =
|
||||||
|
Catalog.list_proposal_parents(:help)
|
||||||
|
|> Enum.find(&(&1.slug == "roadside-help"))
|
||||||
|
|
||||||
|
{:ok, help_proposal} =
|
||||||
|
Catalog.propose(context.requester_scope, %{
|
||||||
|
"proposed_name" => "Cargo bicycle",
|
||||||
|
"parent_id" => roadside_parent.id,
|
||||||
|
"mode" => "activity",
|
||||||
|
"reason" => "A reusable help category for cargo bicycle roadside problems."
|
||||||
|
})
|
||||||
|
|
||||||
|
assert help_proposal.mode == :help
|
||||||
|
|
||||||
|
assert {:ok, %{category: created}} =
|
||||||
|
Catalog.approve_proposal(moderator_scope, help_proposal.id, %{
|
||||||
|
"slug" => "cargo-bicycle-#{System.unique_integer([:positive])}",
|
||||||
|
"names" => %{"en" => "Cargo bicycle help"},
|
||||||
|
"mode" => "activity",
|
||||||
|
"parent_id" => activity_category.id
|
||||||
|
})
|
||||||
|
|
||||||
|
assert created.mode == :help
|
||||||
|
assert created.parent_id == roadside_parent.id
|
||||||
|
end
|
||||||
|
|
||||||
test "tracking derives movement and proximity from browser accuracy envelopes", context do
|
test "tracking derives movement and proximity from browser accuracy envelopes", context do
|
||||||
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
|
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
|
||||||
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
||||||
describe "authenticated support" do
|
describe "authenticated support" do
|
||||||
setup :register_and_log_in_user
|
setup :register_and_log_in_user
|
||||||
|
|
||||||
test "lists the current user's cases and links account deletion from settings", %{
|
test "lists the current user's support cases and removal notices", %{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
user: user
|
user: user
|
||||||
} do
|
} do
|
||||||
|
|
@ -91,9 +91,50 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
||||||
"details" => "I need assistance understanding an account setting."
|
"details" => "I need assistance understanding an account setting."
|
||||||
})
|
})
|
||||||
|
|
||||||
|
{:ok, notice} =
|
||||||
|
WhoNeedHelp.ContentRemoval.create_notice(user_scope_fixture(user), :general, %{
|
||||||
|
"category" => "privacy_violation",
|
||||||
|
"submitter_name" => "Account Owner",
|
||||||
|
"relationship" => "self",
|
||||||
|
"content_locations" => "https://example.test/requests/private-content",
|
||||||
|
"explanation" =>
|
||||||
|
"This notice verifies that a signed-in account can revisit its private case.",
|
||||||
|
"electronic_signature" => "Account Owner",
|
||||||
|
"good_faith" => "true",
|
||||||
|
"accurate_complete" => "true"
|
||||||
|
})
|
||||||
|
|
||||||
assert html_response(get(conn, ~p"/support/requests"), 200) =~ request.reference
|
assert html_response(get(conn, ~p"/support/requests"), 200) =~ request.reference
|
||||||
|
|
||||||
|
assert html_response(get(conn, ~p"/legal/content-removal/requests"), 200) =~
|
||||||
|
notice.reference
|
||||||
|
|
||||||
|
assert html_response(get(conn, ~p"/legal/content-removal/#{notice.id}"), 200) =~
|
||||||
|
notice.reference
|
||||||
|
|
||||||
assert html_response(get(conn, ~p"/users/settings"), 200) =~ ~p"/account/delete"
|
assert html_response(get(conn, ~p"/users/settings"), 200) =~ ~p"/account/delete"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "does not expose another account's removal notice without its token", %{
|
||||||
|
conn: conn
|
||||||
|
} do
|
||||||
|
owner = user_fixture()
|
||||||
|
|
||||||
|
{:ok, notice} =
|
||||||
|
WhoNeedHelp.ContentRemoval.create_notice(user_scope_fixture(owner), :general, %{
|
||||||
|
"category" => "privacy_violation",
|
||||||
|
"submitter_name" => "Different Owner",
|
||||||
|
"relationship" => "self",
|
||||||
|
"content_locations" => "https://example.test/requests/other-account",
|
||||||
|
"explanation" =>
|
||||||
|
"This notice must remain private from a different authenticated account.",
|
||||||
|
"electronic_signature" => "Different Owner",
|
||||||
|
"good_faith" => "true",
|
||||||
|
"accurate_complete" => "true"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response(get(conn, ~p"/legal/content-removal/#{notice.id}"), 404) == "Not found"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "operator queue authorization" do
|
describe "operator queue authorization" do
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,71 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
assert html =~ "Repeated help between the same pair"
|
assert html =~ "Repeated help between the same pair"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "public profile reveals trust data without exposing private account fields", %{conn: conn} do
|
||||||
|
category = Catalog.seed_defaults()
|
||||||
|
|
||||||
|
requester =
|
||||||
|
user_fixture(display_name: "Public reviewer")
|
||||||
|
|> Ecto.Changeset.change(moderation_note: "never render this note")
|
||||||
|
|> Repo.update!()
|
||||||
|
|
||||||
|
helper = user_fixture(display_name: "Public helper")
|
||||||
|
|
||||||
|
{:ok, helper} =
|
||||||
|
Accounts.update_user_profile(helper, %{
|
||||||
|
"display_name" => helper.display_name,
|
||||||
|
"bio" => "I help with bicycles and medicine pickup.",
|
||||||
|
"locale" => helper.locale,
|
||||||
|
"location_visibility" => helper.location_visibility,
|
||||||
|
"direct_message_policy" => helper.direct_message_policy,
|
||||||
|
"tip_url" => "https://example.com/thanks"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _identity} =
|
||||||
|
Accounts.add_social_identity(helper, %{
|
||||||
|
"provider" => "telegram",
|
||||||
|
"profile_url" => "https://t.me/public_helper",
|
||||||
|
"handle" => "@public_helper"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, request} =
|
||||||
|
Help.create_request(Accounts.Scope.for_user(requester), request_attrs(category))
|
||||||
|
|
||||||
|
{:ok, assignment} = Help.accept_request(Accounts.Scope.for_user(helper), request.id)
|
||||||
|
{:ok, _} = Help.confirm_completion(Accounts.Scope.for_user(requester), assignment.id)
|
||||||
|
{:ok, _} = Help.confirm_completion(Accounts.Scope.for_user(helper), assignment.id)
|
||||||
|
|
||||||
|
{:ok, assignment} =
|
||||||
|
Help.verify_handover(
|
||||||
|
Accounts.Scope.for_user(helper),
|
||||||
|
assignment.id,
|
||||||
|
Help.handover_code(request.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Trust.submit_review(Accounts.Scope.for_user(requester), assignment, %{
|
||||||
|
"rating" => "5",
|
||||||
|
"comment" => "Reliable and kind."
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Trust.submit_review(Accounts.Scope.for_user(helper), assignment, %{
|
||||||
|
"rating" => "4",
|
||||||
|
"comment" => "Clear request."
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/people/#{helper.id}")
|
||||||
|
|
||||||
|
assert html =~ "Public helper"
|
||||||
|
assert html =~ "I help with bicycles and medicine pickup."
|
||||||
|
assert html =~ "Reliable and kind."
|
||||||
|
assert html =~ "Public reviewer"
|
||||||
|
assert html =~ "https://t.me/public_helper"
|
||||||
|
assert html =~ "https://example.com/thanks"
|
||||||
|
refute html =~ helper.email
|
||||||
|
refute html =~ "never render this note"
|
||||||
|
end
|
||||||
|
|
||||||
test "authenticated LiveView honors the locale stored by the browser pipeline", %{conn: conn} do
|
test "authenticated LiveView honors the locale stored by the browser pipeline", %{conn: conn} do
|
||||||
conn = get(conn, ~p"/?locale=uk")
|
conn = get(conn, ~p"/?locale=uk")
|
||||||
{:ok, _view, html} = live(conn, ~p"/requests")
|
{:ok, _view, html} = live(conn, ~p"/requests")
|
||||||
|
|
@ -860,6 +925,55 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
assert render(view) =~ "Chain repair"
|
assert render(view) =~ "Chain repair"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "category voting toggles and parent choices follow the selected mode", %{
|
||||||
|
conn: conn,
|
||||||
|
user: user
|
||||||
|
} do
|
||||||
|
Catalog.seed_defaults()
|
||||||
|
|
||||||
|
{:ok, proposal} =
|
||||||
|
Catalog.propose(Accounts.Scope.for_user(user), %{
|
||||||
|
"proposed_name" => "Cargo bicycle help",
|
||||||
|
"mode" => "help",
|
||||||
|
"reason" => "A reusable category for cargo bicycle roadside assistance."
|
||||||
|
})
|
||||||
|
|
||||||
|
roadside = Enum.find(Catalog.list_proposal_parents(:help), &(&1.slug == "roadside-help"))
|
||||||
|
activity_parent = Catalog.list_proposal_parents(:activity) |> List.first()
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/categories/proposals")
|
||||||
|
|
||||||
|
assert has_element?(view, "#proposal-vote-#{proposal.id}[aria-pressed='false']")
|
||||||
|
assert has_element?(view, "#category-proposal-form option[value='#{roadside.id}']")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("#proposal-vote-#{proposal.id}")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert has_element?(view, "#proposal-unvote-#{proposal.id}[aria-pressed='true']")
|
||||||
|
assert render(view) =~ "Voted · 1"
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("#proposal-unvote-#{proposal.id}")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
assert has_element?(view, "#proposal-vote-#{proposal.id}[aria-pressed='false']")
|
||||||
|
assert render(view) =~ "Vote · 0"
|
||||||
|
|
||||||
|
view
|
||||||
|
|> form("#category-proposal-form", category_proposal: %{mode: "activity"})
|
||||||
|
|> render_change()
|
||||||
|
|
||||||
|
refute has_element?(view, "#category-proposal-form option[value='#{roadside.id}']")
|
||||||
|
|
||||||
|
if activity_parent do
|
||||||
|
assert has_element?(
|
||||||
|
view,
|
||||||
|
"#category-proposal-form option[value='#{activity_parent.id}']"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
test "review submission updates both participant pages and hides the submitted form" do
|
test "review submission updates both participant pages and hides the submitted form" do
|
||||||
category = Catalog.seed_defaults()
|
category = Catalog.seed_defaults()
|
||||||
requester = user_fixture(display_name: "Review requester")
|
requester = user_fixture(display_name: "Review requester")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user