Improve category hierarchy selection and proposals

This commit is contained in:
SimpleTest 2026-07-25 20:49:02 +03:00
parent 539076c163
commit 93bf508bdb
14 changed files with 1765 additions and 727 deletions

View File

@ -677,6 +677,234 @@ html {
line-height: 1.4; line-height: 1.4;
} }
.category-picker {
position: relative;
width: 100%;
min-width: 0;
margin-bottom: 0.5rem;
}
.category-picker-ui {
display: none;
}
.category-picker.is-enhanced .category-picker-native {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.category-picker.is-enhanced .category-picker-ui {
display: block;
}
.category-picker-trigger {
display: flex;
width: 100%;
min-height: 3rem;
cursor: pointer;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.65rem 0.85rem;
border: var(--border) solid var(--color-base-300);
border-radius: var(--radius-field);
color: var(--color-base-content);
background: var(--color-base-100);
text-align: start;
transition: border-color 120ms ease, box-shadow 120ms ease, background-color 120ms ease;
}
.category-picker-trigger:hover {
border-color: color-mix(in oklab, var(--color-base-content) 35%, var(--color-base-300));
}
.category-picker-trigger:focus-visible,
.category-picker:focus-within .category-picker-trigger[aria-expanded="true"] {
border-color: var(--color-success);
outline: 2px solid color-mix(in oklab, var(--color-success) 35%, transparent);
outline-offset: 1px;
}
.category-picker-error .category-picker-trigger {
border-color: var(--color-error);
}
.category-picker-placeholder {
color: color-mix(in oklab, var(--color-base-content) 48%, transparent);
}
.category-picker-panel {
position: absolute;
z-index: 40;
inset-block-start: calc(100% + 0.4rem);
inset-inline: 0;
overflow: hidden;
border: 1px solid var(--color-base-300);
border-radius: 0.9rem;
background: var(--color-base-100);
box-shadow: 0 1rem 2.5rem color-mix(in oklab, var(--color-base-content) 18%, transparent);
}
.category-picker-search {
display: flex;
align-items: center;
gap: 0.65rem;
margin: 0.75rem;
padding: 0 0.75rem;
border: 1px solid var(--color-base-300);
border-radius: 0.65rem;
background: var(--color-base-200);
}
.category-picker-search:focus-within {
border-color: var(--color-success);
outline: 2px solid color-mix(in oklab, var(--color-success) 28%, transparent);
}
.category-picker-search input {
width: 100%;
min-width: 0;
height: 2.75rem;
border: 0;
outline: 0;
color: var(--color-base-content);
background: transparent;
}
.category-picker-options {
max-height: min(26rem, 55vh);
overflow-y: auto;
overscroll-behavior: contain;
padding: 0 0.5rem 0.65rem;
}
.category-picker-group + .category-picker-group {
margin-top: 0.45rem;
padding-top: 0.45rem;
border-top: 1px solid var(--color-base-300);
}
.category-picker-group-label {
padding: 0.45rem 0.65rem 0.35rem;
color: color-mix(in oklab, var(--color-base-content) 62%, transparent);
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.category-picker-option {
display: flex;
width: 100%;
cursor: pointer;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 0.65rem;
border-radius: 0.6rem;
color: var(--color-base-content);
background: transparent;
text-align: start;
}
.category-picker-option:hover,
.category-picker-option:focus-visible {
outline: 0;
background: var(--color-base-200);
}
.category-picker-option[aria-selected="true"] {
color: var(--color-success);
background: color-mix(in oklab, var(--color-success) 10%, var(--color-base-100));
}
.category-picker-option span {
display: grid;
min-width: 0;
gap: 0.15rem;
}
.category-picker-option strong {
font-size: 0.9rem;
line-height: 1.3;
}
.category-picker-option small {
display: -webkit-box;
overflow: hidden;
color: color-mix(in oklab, var(--color-base-content) 58%, transparent);
font-size: 0.76rem;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.category-picker-check {
color: var(--color-success);
}
.category-picker-empty {
padding: 1.5rem 1rem;
color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
text-align: center;
}
.category-proposal-kind {
position: relative;
display: flex;
min-height: 5.5rem;
cursor: pointer;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--color-base-300);
border-radius: 0.85rem;
background: var(--color-base-100);
transition: border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease;
}
.category-proposal-kind:hover {
border-color: color-mix(in oklab, var(--color-success) 55%, var(--color-base-300));
}
.category-proposal-kind:focus-within {
outline: 2px solid color-mix(in oklab, var(--color-success) 35%, transparent);
outline-offset: 2px;
}
.category-proposal-kind.is-selected {
border-color: var(--color-success);
color: var(--color-success);
background: color-mix(in oklab, var(--color-success) 9%, var(--color-base-100));
}
.category-proposal-kind input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.category-proposal-kind > span {
display: grid;
gap: 0.2rem;
}
.category-proposal-kind strong {
color: var(--color-base-content);
font-size: 0.9rem;
}
.category-proposal-kind small {
color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
font-size: 0.76rem;
line-height: 1.35;
}
.request-location-workspace { .request-location-workspace {
padding: 1.25rem; padding: 1.25rem;
border: 1px solid var(--color-base-300); border: 1px solid var(--color-base-300);

View File

@ -776,6 +776,172 @@ export const mountStaticAidMaps = root => {
} }
export const Hooks = { export const Hooks = {
SearchableCategoryPicker: {
mounted() {
this.refresh = () => {
this.el.classList.add("is-enhanced")
this.source = this.el.querySelector("[data-category-picker-source]")
this.trigger = this.el.querySelector("[data-category-picker-trigger]")
this.label = this.el.querySelector("[data-category-picker-label]")
this.panel = this.el.querySelector("[data-category-picker-panel]")
this.search = this.el.querySelector("[data-category-picker-search]")
this.options = [...this.el.querySelectorAll("[data-category-picker-option]")]
this.groups = [...this.el.querySelectorAll("[data-category-picker-group]")]
this.empty = this.el.querySelector("[data-category-picker-empty]")
if (this.source) {
this.source.hidden = true
this.source.tabIndex = -1
this.source.setAttribute("aria-hidden", "true")
}
this.syncSelection()
}
this.isOpen = () => this.panel && !this.panel.hidden
this.open = () => {
if (!this.panel || !this.trigger || !this.search) return
this.panel.hidden = false
this.trigger.setAttribute("aria-expanded", "true")
this.search.setAttribute("aria-expanded", "true")
this.search.focus()
this.search.select()
}
this.close = ({restoreFocus = false} = {}) => {
if (!this.panel || !this.trigger || !this.search) return
this.panel.hidden = true
this.trigger.setAttribute("aria-expanded", "false")
this.search.setAttribute("aria-expanded", "false")
this.search.value = ""
this.filter("")
if (restoreFocus) this.trigger.focus()
}
this.filter = rawQuery => {
const query = rawQuery.trim().toLocaleLowerCase()
let visibleCount = 0
this.groups.forEach(group => {
let groupVisible = false
const groupMatches = (group.dataset.searchText || "").includes(query)
group.querySelectorAll("[data-category-picker-option]").forEach(option => {
const visible = query === "" ||
groupMatches ||
(option.dataset.searchText || "").includes(query)
option.hidden = !visible
groupVisible ||= visible
if (visible) visibleCount += 1
})
group.hidden = !groupVisible
})
if (this.empty) this.empty.hidden = visibleCount !== 0
}
this.syncSelection = () => {
if (!this.source || !this.trigger || !this.label) return
const selected = this.options.find(option => option.dataset.value === this.source.value)
const prompt = this.source.options[0]?.textContent?.trim() || ""
this.label.textContent = selected?.dataset.label || prompt
this.label.classList.toggle("category-picker-placeholder", !selected)
this.options.forEach(option => {
const active = option === selected
option.setAttribute("aria-selected", String(active))
option.querySelector(".category-picker-check")?.classList.toggle("invisible", !active)
})
}
this.select = option => {
if (!this.source) return
const value = option.dataset.value || ""
const changed = this.source.value !== value
this.source.value = value
this.syncSelection()
this.close({restoreFocus: true})
if (changed) {
this.source.dispatchEvent(new Event("input", {bubbles: true}))
}
}
this.onClick = event => {
const trigger = event.target.closest("[data-category-picker-trigger]")
if (trigger && this.el.contains(trigger)) {
event.preventDefault()
this.isOpen() ? this.close() : this.open()
return
}
const option = event.target.closest("[data-category-picker-option]")
if (option && this.el.contains(option)) {
event.preventDefault()
this.select(option)
}
}
this.onInput = event => {
if (!event.target.matches("[data-category-picker-search]")) return
event.stopPropagation()
this.filter(event.target.value)
}
this.onKeydown = event => {
if (
event.target.matches("[data-category-picker-trigger]") &&
["ArrowDown", "Enter", " "].includes(event.key)
) {
event.preventDefault()
this.open()
return
}
if (!event.target.matches("[data-category-picker-search]")) return
if (event.key === "Escape") {
event.preventDefault()
this.close({restoreFocus: true})
return
}
if (event.key === "ArrowDown") {
event.preventDefault()
this.options.find(option => !option.hidden && !option.closest("section")?.hidden)?.focus()
}
}
this.onDocumentPointerDown = event => {
if (this.isOpen() && !this.el.contains(event.target)) this.close()
}
this.el.addEventListener("click", this.onClick)
this.el.addEventListener("input", this.onInput)
this.el.addEventListener("keydown", this.onKeydown)
document.addEventListener("pointerdown", this.onDocumentPointerDown)
this.refresh()
},
updated() {
this.refresh()
},
destroyed() {
if (this.source) {
this.source.hidden = false
this.source.removeAttribute("aria-hidden")
this.source.removeAttribute("tabindex")
}
this.el.removeEventListener("click", this.onClick)
this.el.removeEventListener("input", this.onInput)
this.el.removeEventListener("keydown", this.onKeydown)
document.removeEventListener("pointerdown", this.onDocumentPointerDown)
}
},
AccessibleDisclosure: { AccessibleDisclosure: {
mounted() { mounted() {
this.summary = this.el.querySelector(":scope > summary") this.summary = this.el.querySelector(":scope > summary")

View File

@ -27,9 +27,8 @@ defmodule WhoNeedHelp.Catalog do
def list_proposal_parents(mode) when mode in [:help, :activity] do def list_proposal_parents(mode) when mode in [:help, :activity] do
Category Category
|> where([category], category.mode == ^mode) |> where([category], category.mode == ^mode and is_nil(category.parent_id))
|> order_by([category], asc: category.sort_order, asc: category.slug) |> order_by([category], asc: category.sort_order, asc: category.slug)
|> preload(:parent)
|> Repo.all() |> Repo.all()
end end
@ -469,7 +468,7 @@ defmodule WhoNeedHelp.Catalog do
case attrs["parent_id"] do case attrs["parent_id"] do
parent_id when parent_id not in [nil, ""] -> parent_id when parent_id not in [nil, ""] ->
with {:ok, parent_id} <- cast_id(parent_id), with {:ok, parent_id} <- cast_id(parent_id),
%Category{} = parent <- Repo.get(Category, parent_id) do %Category{parent_id: nil} = parent <- Repo.get(Category, parent_id) do
{:ok, Map.put(attrs, "mode", to_string(parent.mode))} {:ok, Map.put(attrs, "mode", to_string(parent.mode))}
else else
_invalid_or_missing -> {:error, :invalid_parent} _invalid_or_missing -> {:error, :invalid_parent}

View File

@ -9,6 +9,7 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do
field :proposed_name, :string field :proposed_name, :string
field :reason, :string field :reason, :string
field :mode, Ecto.Enum, values: [:help, :activity], default: :help field :mode, Ecto.Enum, values: [:help, :activity], default: :help
field :kind, Ecto.Enum, values: [:category, :subcategory], virtual: true
field :status, Ecto.Enum, values: [:open, :approved, :rejected, :merged], default: :open field :status, Ecto.Enum, values: [:open, :approved, :rejected, :merged], default: :open
belongs_to :proposer, WhoNeedHelp.Accounts.User belongs_to :proposer, WhoNeedHelp.Accounts.User
belongs_to :parent, WhoNeedHelp.Catalog.Category belongs_to :parent, WhoNeedHelp.Catalog.Category
@ -24,11 +25,14 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do
end end
def changeset(proposal, attrs) do def changeset(proposal, attrs) do
attrs = normalize_kind(attrs)
proposal proposal
|> cast(attrs, [:proposed_name, :reason, :mode, :parent_id]) |> cast(attrs, [:proposed_name, :reason, :mode, :kind, :parent_id])
|> validate_required([:proposed_name, :reason, :mode]) |> validate_required([:proposed_name, :reason, :mode, :kind])
|> validate_length(:proposed_name, min: 2, max: 80) |> validate_length(:proposed_name, min: 2, max: 80)
|> validate_length(:reason, min: 5, max: 500) |> validate_length(:reason, min: 5, max: 500)
|> validate_parent_for_kind()
end end
def moderation_changeset(proposal, attrs) do def moderation_changeset(proposal, attrs) do
@ -37,4 +41,34 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do
|> validate_required([:status, :reviewed_by_id, :reviewed_at]) |> validate_required([:status, :reviewed_by_id, :reviewed_at])
|> validate_length(:moderation_note, max: 1_000) |> validate_length(:moderation_note, max: 1_000)
end end
defp normalize_kind(attrs) when is_map(attrs) do
string_attrs = Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
kind =
case string_attrs["kind"] do
kind when kind in ["category", "subcategory", :category, :subcategory] ->
kind
_missing_or_invalid ->
if string_attrs["parent_id"] in [nil, ""], do: "category", else: "subcategory"
end
Map.put(string_attrs, "kind", kind)
end
defp validate_parent_for_kind(changeset) do
case get_field(changeset, :kind) do
:category ->
put_change(changeset, :parent_id, nil)
:subcategory ->
validate_required(changeset, [:parent_id],
message: "choose the main category for this subcategory"
)
_invalid_or_missing ->
changeset
end
end
end end

View File

@ -30,6 +30,7 @@ defmodule WhoNeedHelpWeb.CoreComponents do
use Gettext, backend: WhoNeedHelpWeb.Gettext use Gettext, backend: WhoNeedHelpWeb.Gettext
alias Phoenix.LiveView.JS alias Phoenix.LiveView.JS
alias WhoNeedHelp.Catalog.Category
@doc """ @doc """
Renders flash notices. Renders flash notices.
@ -176,6 +177,207 @@ defmodule WhoNeedHelpWeb.CoreComponents do
""" """
end end
@doc """
Renders an accessible searchable category picker.
Categories with a parent are grouped under that parent. Active standalone
categories remain selectable, so community-approved top-level categories do
not require a separate rendering path.
"""
attr :field, Phoenix.HTML.FormField, required: true
attr :categories, :list, required: true
attr :label, :string, required: true
attr :prompt, :string, required: true
attr :search_placeholder, :string, required: true
attr :empty_message, :string, required: true
attr :id, :string, default: nil
def category_picker(assigns) do
locale = Gettext.get_locale(WhoNeedHelpWeb.Gettext)
field = assigns.field
selected_id = if field.value in [nil, ""], do: "", else: to_string(field.value)
groups = category_picker_groups(assigns.categories, locale)
selected_label =
groups
|> Enum.flat_map(& &1.options)
|> Enum.find_value(assigns.prompt, fn option ->
if option.id == selected_id, do: option.path
end)
errors =
if Phoenix.Component.used_input?(field),
do: Enum.map(field.errors, &translate_error(&1)),
else: []
assigns =
assigns
|> assign(:picker_id, assigns.id || "#{field.id}-picker")
|> assign(:field_id, field.id)
|> assign(:field_name, field.name)
|> assign(:selected_id, selected_id)
|> assign(:selected_label, selected_label)
|> assign(:groups, groups)
|> assign(:errors, errors)
~H"""
<div
id={@picker_id}
class={["category-picker", @errors != [] && "category-picker-error"]}
phx-hook="SearchableCategoryPicker"
>
<span class="label mb-1 block max-w-full whitespace-normal break-words">{@label}</span>
<select
id={@field_id}
name={@field_name}
class={["select w-full category-picker-native", @errors != [] && "select-error"]}
data-category-picker-source
>
<option value="">{@prompt}</option>
<optgroup :for={group <- @groups} label={group.label || @label}>
<option
:for={option <- group.options}
value={option.id}
selected={option.id == @selected_id}
>
{option.path}
</option>
</optgroup>
</select>
<div class="category-picker-ui">
<button
type="button"
class="category-picker-trigger"
data-category-picker-trigger
aria-haspopup="listbox"
aria-expanded="false"
aria-controls={"#{@picker_id}-panel"}
>
<span
class={[@selected_id == "" && "category-picker-placeholder"]}
data-category-picker-label
>
{@selected_label}
</span>
<.icon name="hero-chevron-up-down" class="size-5 shrink-0" />
</button>
<div
id={"#{@picker_id}-panel"}
class="category-picker-panel"
data-category-picker-panel
hidden
>
<label class="category-picker-search">
<.icon name="hero-magnifying-glass" class="size-5 shrink-0" />
<span class="sr-only">{@search_placeholder}</span>
<input
id={"#{@picker_id}-search"}
type="search"
placeholder={@search_placeholder}
autocomplete="off"
role="combobox"
aria-autocomplete="list"
aria-expanded="false"
aria-controls={"#{@picker_id}-options"}
data-category-picker-search
/>
</label>
<div
id={"#{@picker_id}-options"}
class="category-picker-options"
role="listbox"
aria-label={@label}
data-category-picker-options
>
<section
:for={group <- @groups}
class="category-picker-group"
data-category-picker-group
data-search-text={group.search_text}
>
<h3 :if={group.label} class="category-picker-group-label">{group.label}</h3>
<button
:for={option <- group.options}
type="button"
class="category-picker-option"
role="option"
aria-selected={option.id == @selected_id}
data-category-picker-option
data-value={option.id}
data-label={option.path}
data-search-text={option.search_text}
>
<span>
<strong>{option.name}</strong>
<small :if={option.description}>{option.description}</small>
</span>
<.icon
name="hero-check"
class={[
"size-5 shrink-0 category-picker-check",
option.id != @selected_id && "invisible"
]}
/>
</button>
</section>
<p class="category-picker-empty" data-category-picker-empty hidden>
{@empty_message}
</p>
</div>
</div>
</div>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
defp category_picker_groups(categories, locale) do
categories
|> Enum.group_by(fn
%{parent: %Category{} = parent} ->
{:parent, parent.id, Category.name(parent, locale), parent.sort_order}
%Category{} = standalone ->
{:standalone, nil, nil, standalone.sort_order}
end)
|> Enum.map(fn
{{_kind, _id, label, group_sort_order}, grouped_categories} ->
options =
Enum.map(grouped_categories, fn category ->
name = Category.name(category, locale)
path = if label, do: "#{label} — #{name}", else: name
%{
id: to_string(category.id),
name: name,
path: path,
description: Category.description(category, locale),
search_text: searchable_text([label, name, Category.description(category, locale)])
}
end)
%{
label: label,
search_text: searchable_text([label]),
sort_order: group_sort_order,
options: options
}
end)
|> Enum.sort_by(&{&1.sort_order, is_nil(&1.label), String.downcase(&1.label || "")})
end
defp searchable_text(parts) do
parts
|> Enum.reject(&is_nil/1)
|> Enum.join(" ")
|> String.downcase()
end
@doc """ @doc """
Renders an input with label and error messages. Renders an input with label and error messages.

View File

@ -168,18 +168,20 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
phx-submit="save" phx-submit="save"
class="mt-8 space-y-5" class="mt-8 space-y-5"
> >
<.input <.category_picker
field={@form[:category_id]} field={@form[:category_id]}
type="select" categories={@categories}
label={gettext("Activity type")} label={gettext("Activity type")}
prompt={gettext("Choose activity")} prompt={gettext("Choose activity")}
options={ search_placeholder={gettext("Search activity categories")}
Enum.map( empty_message={gettext("No matching activity. You can propose a new one.")}
@categories,
&{Catalog.category_path(&1, Gettext.get_locale(WhoNeedHelpWeb.Gettext)), &1.id}
)
}
/> />
<.link
navigate={~p"/categories/proposals"}
class="-mt-3 inline-flex text-sm font-semibold text-info underline-offset-4 hover:underline"
>
{gettext("Can't find the right option? Propose a category")}
</.link>
<p <p
:if={@selected_category} :if={@selected_category}
id="selected-activity-category-description" id="selected-activity-category-description"

View File

@ -29,6 +29,8 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
def handle_event("validate", %{"category_proposal" => params}, socket) do def handle_event("validate", %{"category_proposal" => params}, socket) do
mode = proposal_mode(params["mode"]) mode = proposal_mode(params["mode"])
kind = proposal_kind(params["kind"])
params = if kind == :category, do: Map.put(params, "parent_id", ""), else: params
form = form =
%CategoryProposal{} %CategoryProposal{}
@ -40,6 +42,7 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
socket socket
|> assign(:form, form) |> assign(:form, form)
|> assign(:proposal_mode, mode) |> assign(:proposal_mode, mode)
|> assign(:proposal_kind, kind)
|> assign(:parent_categories, Catalog.list_proposal_parents(mode))} |> assign(:parent_categories, Catalog.list_proposal_parents(mode))}
end end
@ -91,20 +94,29 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
defp load(socket) do defp load(socket) do
proposals = Catalog.paginate_proposals_for(socket.assigns.current_scope) proposals = Catalog.paginate_proposals_for(socket.assigns.current_scope)
mode = socket.assigns[:proposal_mode] || :help mode = socket.assigns[:proposal_mode] || :help
kind = socket.assigns[:proposal_kind] || :subcategory
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(:proposal_mode, mode) |> assign(:proposal_mode, mode)
|> assign(:proposal_kind, kind)
|> assign(:parent_categories, Catalog.list_proposal_parents(mode)) |> assign(:parent_categories, Catalog.list_proposal_parents(mode))
|> assign(:form, to_form(Catalog.change_proposal(%CategoryProposal{mode: mode}))) |> assign(
:form,
to_form(Catalog.change_proposal(%CategoryProposal{mode: mode, kind: kind}))
)
end end
defp proposal_mode("activity"), do: :activity defp proposal_mode("activity"), do: :activity
defp proposal_mode(:activity), do: :activity defp proposal_mode(:activity), do: :activity
defp proposal_mode(_mode), do: :help defp proposal_mode(_mode), do: :help
defp proposal_kind("category"), do: :category
defp proposal_kind(:category), do: :category
defp proposal_kind(_kind), do: :subcategory
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -127,33 +139,73 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
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"
> >
<.input
field={@form[:proposed_name]}
label={gettext("Proposed category")}
placeholder={gettext("Bicycle puncture")}
/>
<.input <.input
field={@form[:mode]} field={@form[:mode]}
type="select" type="select"
label={gettext("Mode")} label={gettext("Where should it appear?")}
options={[ options={[
{gettext("Urgent help"), "help"}, {gettext("Urgent help"), "help"},
{gettext("Social activity"), "activity"} {gettext("Social activity"), "activity"}
]} ]}
/> />
<.input <fieldset>
<legend class="label mb-2">{gettext("What are you proposing?")}</legend>
<div class="grid gap-3 sm:grid-cols-2">
<label class={[
"category-proposal-kind",
@proposal_kind == :category && "is-selected"
]}>
<input
type="radio"
name={@form[:kind].name}
value="category"
checked={@proposal_kind == :category}
/>
<.icon name="hero-folder-plus" class="size-6 shrink-0" />
<span>
<strong>{gettext("New main category")}</strong>
<small>{gettext("A new broad group of help or activities")}</small>
</span>
</label>
<label class={[
"category-proposal-kind",
@proposal_kind == :subcategory && "is-selected"
]}>
<input
type="radio"
name={@form[:kind].name}
value="subcategory"
checked={@proposal_kind == :subcategory}
/>
<.icon name="hero-list-bullet" class="size-6 shrink-0" />
<span>
<strong>{gettext("New subcategory")}</strong>
<small>{gettext("A specific option inside an existing category")}</small>
</span>
</label>
</div>
</fieldset>
<.category_picker
:if={@proposal_kind == :subcategory}
id="category-proposal-parent-picker"
field={@form[:parent_id]} field={@form[:parent_id]}
type="select" categories={@parent_categories}
label={gettext("Parent (optional)")} label={gettext("Main category")}
prompt={gettext("Top level")} prompt={gettext("Choose the main category")}
options={ search_placeholder={gettext("Search main categories")}
Enum.map( empty_message={gettext("No matching main category")}
@parent_categories, />
&{WhoNeedHelp.Catalog.category_path( <.input
&1, field={@form[:proposed_name]}
Gettext.get_locale(WhoNeedHelpWeb.Gettext) label={
), &1.id} if @proposal_kind == :subcategory,
) do: gettext("Subcategory name"),
else: gettext("Category name")
}
placeholder={
if @proposal_kind == :subcategory,
do: gettext("Bicycle puncture"),
else: gettext("Home help")
} }
/> />
<.input <.input
@ -183,6 +235,18 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
do: gettext("Urgent help"), do: gettext("Urgent help"),
else: gettext("Social activity")} else: gettext("Social activity")}
</span> </span>
<span class="badge badge-sm badge-ghost">
{if proposal.parent,
do:
gettext("Subcategory of %{category}",
category:
WhoNeedHelp.Catalog.Category.name(
proposal.parent,
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
)
),
else: gettext("Main category")}
</span>
<p class="mt-2 text-sm text-base-content/65">{proposal.reason}</p> <p class="mt-2 text-sm text-base-content/65">{proposal.reason}</p>
<p class="mt-3 text-xs text-base-content/45"> <p class="mt-3 text-xs text-base-content/45">
{gettext("by %{name}", name: proposal.proposer.display_name)} {gettext("by %{name}", name: proposal.proposer.display_name)}

View File

@ -296,21 +296,20 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
</div> </div>
</div> </div>
<div class="space-y-5"> <div class="space-y-5">
<.input <.category_picker
field={@form[:category_id]} field={@form[:category_id]}
type="select" categories={@categories}
label={gettext("Category")} label={gettext("Category")}
prompt={gettext("Choose category")} prompt={gettext("Choose category")}
options={ search_placeholder={gettext("Search categories and types of help")}
Enum.map( empty_message={gettext("No matching help type. You can propose a new one.")}
@categories,
&{WhoNeedHelp.Catalog.category_path(
&1,
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
), &1.id}
)
}
/> />
<.link
navigate={~p"/categories/proposals"}
class="-mt-3 inline-flex text-sm font-semibold text-success underline-offset-4 hover:underline"
>
{gettext("Can't find the right option? Propose a category")}
</.link>
<p <p
:if={ :if={
category_description( category_description(

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

@ -227,6 +227,17 @@ defmodule WhoNeedHelp.TrustSafetyTest do
assert created.mode == :help assert created.mode == :help
assert created.parent_id == roadside_parent.id assert created.parent_id == roadside_parent.id
roadside_child =
Catalog.list_categories(:help)
|> Enum.find(&(&1.parent_id == roadside_parent.id))
assert {:error, :invalid_parent} =
Catalog.propose(context.requester_scope, %{
"proposed_name" => "Too deeply nested",
"parent_id" => roadside_child.id,
"reason" => "This proposal must not create a third taxonomy level."
})
end 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

View File

@ -1413,6 +1413,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
category_proposal: %{ category_proposal: %{
proposed_name: "Chain repair", proposed_name: "Chain repair",
mode: "help", mode: "help",
kind: "category",
reason: "A reusable category for urgent roadside bicycle chain repair." reason: "A reusable category for urgent roadside bicycle chain repair."
} }
) )
@ -1442,6 +1443,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert has_element?(view, "#proposal-vote-#{proposal.id}[aria-pressed='false']") assert has_element?(view, "#proposal-vote-#{proposal.id}[aria-pressed='false']")
assert has_element?(view, "#category-proposal-form option[value='#{roadside.id}']") assert has_element?(view, "#category-proposal-form option[value='#{roadside.id}']")
assert has_element?(view, "#category-proposal-form input[value='subcategory'][checked]")
assert has_element?(view, "#category-proposal-parent-picker")
view view
|> element("#proposal-vote-#{proposal.id}") |> element("#proposal-vote-#{proposal.id}")
@ -1469,6 +1472,28 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
"#category-proposal-form option[value='#{activity_parent.id}']" "#category-proposal-form option[value='#{activity_parent.id}']"
) )
end end
view
|> form("#category-proposal-form", category_proposal: %{kind: "category"})
|> render_change()
refute has_element?(view, "#category-proposal-parent-picker")
end
test "request category picker exposes searchable grouped categories", %{conn: conn} do
Catalog.seed_defaults()
{:ok, view, _html} = live(conn, ~p"/requests/new")
assert has_element?(
view,
"#help_request_category_id-picker[phx-hook='SearchableCategoryPicker']"
)
assert has_element?(view, "[data-category-picker-search]")
assert has_element?(view, "[data-category-picker-group]", "Roadside help")
assert has_element?(view, "[data-category-picker-option]", "Bicycle roadside help")
assert has_element?(view, "a[href='/categories/proposals']", "Propose a category")
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