Improve category hierarchy selection and proposals
This commit is contained in:
parent
539076c163
commit
93bf508bdb
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user