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;
}
.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 {
padding: 1.25rem;
border: 1px solid var(--color-base-300);

View File

@ -776,6 +776,172 @@ export const mountStaticAidMaps = root => {
}
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: {
mounted() {
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
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)
|> preload(:parent)
|> Repo.all()
end
@ -469,7 +468,7 @@ defmodule WhoNeedHelp.Catalog do
case attrs["parent_id"] do
parent_id when parent_id not in [nil, ""] ->
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))}
else
_invalid_or_missing -> {:error, :invalid_parent}

View File

@ -9,6 +9,7 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do
field :proposed_name, :string
field :reason, :string
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
belongs_to :proposer, WhoNeedHelp.Accounts.User
belongs_to :parent, WhoNeedHelp.Catalog.Category
@ -24,11 +25,14 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do
end
def changeset(proposal, attrs) do
attrs = normalize_kind(attrs)
proposal
|> cast(attrs, [:proposed_name, :reason, :mode, :parent_id])
|> validate_required([:proposed_name, :reason, :mode])
|> cast(attrs, [:proposed_name, :reason, :mode, :kind, :parent_id])
|> validate_required([:proposed_name, :reason, :mode, :kind])
|> validate_length(:proposed_name, min: 2, max: 80)
|> validate_length(:reason, min: 5, max: 500)
|> validate_parent_for_kind()
end
def moderation_changeset(proposal, attrs) do
@ -37,4 +41,34 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do
|> validate_required([:status, :reviewed_by_id, :reviewed_at])
|> validate_length(:moderation_note, max: 1_000)
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

View File

@ -30,6 +30,7 @@ defmodule WhoNeedHelpWeb.CoreComponents do
use Gettext, backend: WhoNeedHelpWeb.Gettext
alias Phoenix.LiveView.JS
alias WhoNeedHelp.Catalog.Category
@doc """
Renders flash notices.
@ -176,6 +177,207 @@ defmodule WhoNeedHelpWeb.CoreComponents do
"""
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 """
Renders an input with label and error messages.

View File

@ -168,18 +168,20 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
phx-submit="save"
class="mt-8 space-y-5"
>
<.input
<.category_picker
field={@form[:category_id]}
type="select"
categories={@categories}
label={gettext("Activity type")}
prompt={gettext("Choose activity")}
options={
Enum.map(
@categories,
&{Catalog.category_path(&1, Gettext.get_locale(WhoNeedHelpWeb.Gettext)), &1.id}
)
}
search_placeholder={gettext("Search activity categories")}
empty_message={gettext("No matching activity. You can propose a new one.")}
/>
<.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
:if={@selected_category}
id="selected-activity-category-description"

View File

@ -29,6 +29,8 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
def handle_event("validate", %{"category_proposal" => params}, socket) do
mode = proposal_mode(params["mode"])
kind = proposal_kind(params["kind"])
params = if kind == :category, do: Map.put(params, "parent_id", ""), else: params
form =
%CategoryProposal{}
@ -40,6 +42,7 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
socket
|> assign(:form, form)
|> assign(:proposal_mode, mode)
|> assign(:proposal_kind, kind)
|> assign(:parent_categories, Catalog.list_proposal_parents(mode))}
end
@ -91,20 +94,29 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
defp load(socket) do
proposals = Catalog.paginate_proposals_for(socket.assigns.current_scope)
mode = socket.assigns[:proposal_mode] || :help
kind = socket.assigns[:proposal_kind] || :subcategory
socket
|> assign(:page_title, gettext("Category proposals"))
|> assign(:proposals, proposals.entries)
|> assign(:proposals_cursor, proposals.next_cursor)
|> assign(:proposal_mode, mode)
|> assign(:proposal_kind, kind)
|> 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
defp proposal_mode("activity"), do: :activity
defp proposal_mode(:activity), do: :activity
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
def render(assigns) do
~H"""
@ -127,33 +139,73 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
phx-submit="propose"
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
field={@form[:mode]}
type="select"
label={gettext("Mode")}
label={gettext("Where should it appear?")}
options={[
{gettext("Urgent help"), "help"},
{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]}
type="select"
label={gettext("Parent (optional)")}
prompt={gettext("Top level")}
options={
Enum.map(
@parent_categories,
&{WhoNeedHelp.Catalog.category_path(
&1,
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
), &1.id}
)
categories={@parent_categories}
label={gettext("Main category")}
prompt={gettext("Choose the main category")}
search_placeholder={gettext("Search main categories")}
empty_message={gettext("No matching main category")}
/>
<.input
field={@form[:proposed_name]}
label={
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
@ -183,6 +235,18 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
do: gettext("Urgent help"),
else: gettext("Social activity")}
</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-3 text-xs text-base-content/45">
{gettext("by %{name}", name: proposal.proposer.display_name)}

View File

@ -296,21 +296,20 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
</div>
</div>
<div class="space-y-5">
<.input
<.category_picker
field={@form[:category_id]}
type="select"
categories={@categories}
label={gettext("Category")}
prompt={gettext("Choose category")}
options={
Enum.map(
@categories,
&{WhoNeedHelp.Catalog.category_path(
&1,
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
), &1.id}
)
}
search_placeholder={gettext("Search categories and types of help")}
empty_message={gettext("No matching help type. You can propose a new one.")}
/>
<.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
:if={
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.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
test "tracking derives movement and proximity from browser accuracy envelopes", context do

View File

@ -1413,6 +1413,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
category_proposal: %{
proposed_name: "Chain repair",
mode: "help",
kind: "category",
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, "#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
|> element("#proposal-vote-#{proposal.id}")
@ -1469,6 +1472,28 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
"#category-proposal-form option[value='#{activity_parent.id}']"
)
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
test "review submission updates both participant pages and hides the submitted form" do