Skip to content

Commit 26b9657

Browse files
YodaLightsabrsampodergaryhtougithub-actions[bot]
authored
[Disbursements] Improve organization selector (#8591)
## Summary of the problem It can be difficult to select organizations as an admin (especially on mobile) because the native dropdown lists thousands of organizations. ## Describe your changes I've built a custom dropdown component we can use on the disbursements page (and in the future, elsewhere, too). For admins, it shows the organization ID and moves the balance into a tooltip. Here's a video: https://github.com/user-attachments/assets/0e893cc8-62db-462b-be5e-9ae4086c60f1 And here's a screenshot on dark mode: <img width="479" alt="image" src="https://github.com/user-attachments/assets/96229c44-6bde-4482-beb1-353dab219c72"> Reverts #8590 --------- Co-authored-by: Sam Poder <[email protected]> Co-authored-by: Gary Tou <[email protected]> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 3367600 commit 26b9657

File tree

7 files changed

+296
-57
lines changed

7 files changed

+296
-57
lines changed

app/assets/stylesheets/components/_forms.scss

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,8 @@ label {
287287

288288
$select: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="%238492a6" viewbox="0 0 32 32"><path d="M0 6 L32 6 L16 28 z"/></svg>');
289289

290-
select {
290+
select,
291+
.input.input--select {
291292
background-image: #{$select};
292293
background-repeat: no-repeat;
293294
background-position: right 0.75rem center;

app/assets/stylesheets/components/_tooltips.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,13 @@
7474
transform: translateX(50%);
7575
}
7676

77+
.tooltipped--i:after {
78+
bottom: 50%;
79+
right: 0.5rem;
80+
margin-top: 0.5rem;
81+
transform: translateY(50%);
82+
}
83+
7784
.tooltipped--xl {
7885
&:after {
7986
max-width: none;

app/controllers/disbursements_controller.rb

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,22 +45,26 @@ def new
4545
name: params[:message]
4646
)
4747

48+
user_event_ids = current_user.organizer_positions.reorder(sort_index: :asc).pluck(:event_id)
49+
4850
@allowed_source_events = if current_user.admin?
49-
Event.all.reorder(Event::CUSTOM_SORT).includes(:plan)
51+
Event.select(:name, :id, :demo_mode, :slug).all.reorder(Event::CUSTOM_SORT).includes(:plan)
5052
else
5153
current_user.events.not_hidden.filter_demo_mode(false)
52-
end
54+
end.to_enum.with_index.sort_by { |e, i| [user_event_ids.index(e.id) || Float::INFINITY, i] }.map(&:first)
5355
@allowed_destination_events = if current_user.admin?
54-
Event.all.reorder(Event::CUSTOM_SORT).includes(:plan)
56+
Event.select(:name, :id, :demo_mode, :can_front_balance, :slug).all.reorder(Event::CUSTOM_SORT).includes(:plan)
57+
elsif @source_event&.plan&.unrestricted_disbursements_enabled?
58+
Event.select(:name, :id, :demo_mode, :can_front_balance, :slug).indexable.includes(:plan)
5559
else
56-
current_user.events.not_hidden.without(@source_event).filter_demo_mode(false)
57-
end
60+
current_user.events.not_hidden.filter_demo_mode(false)
61+
end.to_enum.with_index.sort_by { |e, i| [user_event_ids.index(e.id) || Float::INFINITY, i] }.map(&:first)
5862

5963
authorize @disbursement
6064
end
6165

6266
def create
63-
@source_event = Event.find(disbursement_params[:source_event_id])
67+
@source_event = Event.find_by_public_id(disbursement_params[:source_event_id])
6468
@destination_event = Event.find_by_public_id(disbursement_params[:event_id]) || Event.friendly.find(disbursement_params[:event_id])
6569
@disbursement = Disbursement.new(destination_event: @destination_event, source_event: @source_event)
6670

@@ -103,6 +107,10 @@ def create
103107
rescue ArgumentError, ActiveRecord::RecordInvalid => e
104108
flash[:error] = e.message
105109
redirect_to new_disbursement_path(source_event_id: @source_event)
110+
rescue ActiveRecord::RecordNotFound => e
111+
skip_authorization
112+
flash[:error] = "Organization not found: #{e.id}"
113+
redirect_to new_disbursement_path(source_event_id: @source_event)
106114
end
107115

108116
def edit
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { Controller } from '@hotwired/stimulus'
2+
import fuzzysort from 'fuzzysort'
3+
4+
export default class extends Controller {
5+
static targets = [
6+
'dropdown',
7+
'menu',
8+
'search',
9+
'organization',
10+
'wrapper',
11+
'field',
12+
'other',
13+
]
14+
static values = {
15+
state: Boolean,
16+
}
17+
18+
connect() {
19+
const organizations = {}
20+
21+
const open = () => {
22+
// eslint-disable-next-line no-undef
23+
$(this.menuTarget).slideDown()
24+
this.searchTarget.style.display = 'block'
25+
this.dropdownTarget.style.display = 'none'
26+
this.searchTarget.select()
27+
}
28+
29+
const close = () => {
30+
// eslint-disable-next-line no-undef
31+
$(this.menuTarget).slideUp()
32+
this.searchTarget.style.display = 'none'
33+
this.dropdownTarget.style.display = 'flex'
34+
}
35+
36+
const filter = async () => {
37+
const orgValues = Object.values(organizations)
38+
39+
const result = fuzzysort.go(this.searchTarget.value, orgValues, {
40+
keys: ['name', 'id', 'slug'],
41+
all: false,
42+
threshold: -500000,
43+
limit: 50,
44+
})
45+
46+
const visible =
47+
result.length > 0
48+
? result.map(r => r.obj)
49+
: this.searchTarget.value.length > 0
50+
? []
51+
: orgValues
52+
.sort((a, b) => a.index - b.index)
53+
54+
.slice(0, 50)
55+
56+
firstOrganization = visible[0]
57+
58+
const shownElements = visible.map(o => o.organization)
59+
60+
if (this.hasOtherTarget && this.searchTarget.value.length > 0) {
61+
shownElements.push(this.otherTarget)
62+
63+
if (shownElements.length == 1)
64+
firstOrganization = organizations['other']
65+
const { button } = organizations['other']
66+
67+
const searchMatchesSelected =
68+
this.searchTarget.value == this.dropdownTarget.value
69+
70+
Object.assign(
71+
button.style,
72+
searchMatchesSelected
73+
? {
74+
backgroundColor: 'var(--info)',
75+
color: 'white',
76+
}
77+
: {
78+
backgroundColor: 'unset',
79+
color: 'unset',
80+
}
81+
)
82+
83+
this.otherTarget.querySelector('.other-name').innerText =
84+
`Other (${this.searchTarget.value})`
85+
}
86+
87+
const hiddenElements = this.allOrganizations.filter(
88+
el => !shownElements.includes(el)
89+
)
90+
91+
for (const element of shownElements) {
92+
element.parentElement.appendChild(element)
93+
element.style.display = 'block'
94+
}
95+
96+
for (const element of hiddenElements) {
97+
element.style.display = 'none'
98+
}
99+
}
100+
101+
for (const organization of this.allOrganizations) {
102+
const { name, id, fee, index, slug } = organization.dataset
103+
const button = organization.children[0]
104+
105+
const select = () => {
106+
const previouslySelected =
107+
organizations[this.dropdownTarget.children[1].value]
108+
if (previouslySelected) {
109+
Object.assign(previouslySelected.button.style, {
110+
backgroundColor: 'unset',
111+
color: 'unset',
112+
})
113+
previouslySelected.button.children[1].style.color = ''
114+
}
115+
116+
Object.assign(button.style, {
117+
backgroundColor: 'var(--info)',
118+
color: 'white',
119+
})
120+
button.children[1].style.color = 'white'
121+
122+
const fieldValue = this.dropdownTarget.children[1]
123+
fieldValue.innerText = button.children[0].innerText
124+
125+
const newValue = id == 'other' ? this.searchTarget.value : id
126+
fieldValue.value = newValue
127+
fieldValue.dataset.fee = fee
128+
this.dropdownTarget.value = newValue
129+
this.dropdownTarget.dispatchEvent(new CustomEvent('feechange'))
130+
131+
close()
132+
}
133+
134+
organizations[id] = {
135+
name,
136+
id,
137+
index,
138+
organization,
139+
button,
140+
select,
141+
fee,
142+
slug,
143+
visible: true,
144+
}
145+
146+
// Select the organization when clicked
147+
button.onclick = e => {
148+
e.preventDefault()
149+
select()
150+
}
151+
}
152+
153+
let firstOrganization = organizations[Object.keys(organizations)[0]]
154+
155+
// Open the dropdown when activated by keyboard
156+
this.dropdownTarget.onkeypress = ({ key }) => {
157+
if (key === 'Enter' || key === ' ') {
158+
open()
159+
return false
160+
}
161+
}
162+
163+
// Open the dropdown when clicked
164+
this.dropdownTarget.onmousedown = e => e.preventDefault()
165+
this.dropdownTarget.onclick = open
166+
167+
// Close dropdown when clicking outside
168+
window.addEventListener('click', ({ target }) => {
169+
if (
170+
!this.wrapperTarget.contains(target) &&
171+
!this.dropdownTarget.contains(target)
172+
)
173+
close()
174+
})
175+
176+
// Select first organization when pressing enter on search
177+
this.searchTarget.onkeypress = ({ key }) => {
178+
if (key === 'Enter') {
179+
firstOrganization?.select?.()
180+
this.dropdownTarget.focus()
181+
return false
182+
}
183+
}
184+
185+
// Close dropdown when pressing escape
186+
this.searchTarget.onkeydown = ({ key }) => {
187+
if (key === 'Escape') close()
188+
this.dropdownTarget.focus()
189+
}
190+
191+
const debounce = (callback, waitTime) => {
192+
let timer
193+
return (...args) => {
194+
clearTimeout(timer)
195+
timer = setTimeout(() => {
196+
callback(...args)
197+
}, waitTime)
198+
}
199+
}
200+
201+
if (this.dropdownTarget.children[1].value) {
202+
const selected = organizations[this.dropdownTarget.children[1].value]
203+
if (selected) {
204+
selected.select()
205+
}
206+
}
207+
208+
// Filter organizations when searching
209+
this.searchTarget.oninput = debounce(filter, 100)
210+
}
211+
212+
get allOrganizations() {
213+
if (this.hasOtherTarget) {
214+
return [...this.organizationTargets, this.otherTarget]
215+
}
216+
217+
return this.organizationTargets
218+
}
219+
}

app/views/disbursements/_form.html.erb

Lines changed: 3 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,10 @@
11
<% disabled = @source_event.present? && !policy(@source_event).create_transfer? %>
22

3-
<%= form_with(model: disbursement, local: true, url: disbursements_path, html: { "x-data" => "{ fee: null, event_id: null }" }) do |form| %>
3+
<%= form_with(model: disbursement, local: true, url: disbursements_path, html: { "x-data" => "{fee: null, event_id: null}" }) do |form| %>
44
<%= form_errors(disbursement, "Disbursements") %>
55

6-
<div class="field event-select-target">
7-
<%= form.label :source_event_id, "From" %>
8-
<%= form.collection_select(:source_event_id,
9-
@allowed_source_events,
10-
:id,
11-
current_user.admin? ? :admin_dropdown_description : :disbursement_dropdown_description,
12-
{ prompt: "Select one…", disabled: ->(e) { e != @source_event && !policy(e).create_transfer? || (!current_user.admin? && e.balance_available <= 0) } },
13-
required: true,
14-
disabled: ) %>
15-
</div>
16-
17-
<div class="field event-select-target">
18-
<%= form.label :destination_event_id_select, "To" %>
19-
<%= form.select(:event_id,
20-
(@allowed_destination_events.map do |event|
21-
[
22-
current_user.admin? ? event.admin_dropdown_description : event.disbursement_dropdown_description,
23-
event.id,
24-
{
25-
"data-fee": event.revenue_fee.to_s,
26-
"data-event_id": event.id
27-
},
28-
]
29-
end) +
30-
(@source_event&.plan&.unrestricted_disbursements_enabled? && !admin_signed_in? ? [[
31-
"Other",
32-
"other",
33-
{
34-
"data-fee": "null",
35-
"data-event_id": "other"
36-
},
37-
]] : []),
38-
{ prompt: "Select one…" },
39-
required: true,
40-
disabled:,
41-
"x-on:input" => "fee = $event.target.options[$event.target.selectedIndex].dataset.fee; event_id = $event.target.options[$event.target.selectedIndex].dataset.event_id; console.log(event_id)") %>
42-
<% unless admin_signed_in? %>
43-
<% if @source_event&.plan&.unrestricted_disbursements_enabled? %>
44-
<template x-if="event_id == 'other'">
45-
<div class="mt2">
46-
<%= form.label :destination_event_id_select, "Destination event's slug" %>
47-
<%= form.text_field :event_id, placeholder: "hack_the_seas", required: true, disabled:, data: { behavior: "extract_slug" } %>
48-
</div>
49-
</template>
50-
<% else %>
51-
<span class="muted">You can transfer to any organization you're a part of.</span>
52-
<% end %>
53-
<% end %>
54-
</div>
6+
<%= render partial: "select_organization", locals: { form: form, field_name: "source_event_id", disabled: disabled, events: @allowed_source_events, default_event: @source_event, sending: true } %>
7+
<%= render partial: "select_organization", locals: { form: form, field_name: "event_id", disabled: disabled, events: @allowed_destination_events, default_event: nil, receiving: true, allow_custom_events: @source_event&.plan&.unrestricted_disbursements_enabled? } %>
558

569
<% if admin_signed_in? %>
5710
<div class="admin-tools field field--checkbox" x-show="fee > 0" x-cloak x-transition.duration.500ms>
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<%# locals: (form:, field_name:, disabled:, events:, default_event:, lazy_load_balances: false, sending: false, receiving: false, allow_custom_events: false) %>
2+
3+
<div class="field" data-controller="organization-select">
4+
<%= form.label field_name, sending ? "From" : "To" %>
5+
<select name="disbursement[<%= field_name %>]" id="disbursement_<%= field_name %>" <%= "disabled" if disabled %> class="bg-[transparent] dark:bg-darkless input input--select flex items-center select-none cursor-pointer disabled:cursor-default" data-organization-select-target="dropdown">
6+
<option value>Select one...</option>
7+
<option <%= "default" if default_event.present? %> value="<%= default_event.public_id if default_event.present? %>"><%= default_event.name if default_event.present? %></option>
8+
</select>
9+
10+
<div data-organization-select-target="wrapper" style="max-width: 384px;">
11+
<input data-organization-select-target="search" style="display: none; border-bottom-left-radius: 0px; border-bottom-right-radius: 0px;" type="text" placeholder="Search">
12+
13+
<div data-organization-select-target="menu" style="display: none; border-width: 1px; border-style: solid; border-top: none; max-width: 384px; border-top-left-radius: 0px; border-top-right-radius: 0px;" class="border-smoke rounded-md dark:border-black overflow-hidden h-40">
14+
<div class="w-full h-full rounded-md overflow-y-scroll" style="border-top-left-radius: 0px; border-top-right-radius: 0px; content-visibility: auto;">
15+
<div>
16+
<% events.each_with_index.map do |event, i| %>
17+
<% disabled_message = nil %>
18+
<% disabled_message = "Insufficient balance" if sending && !current_user.admin? && event.balance_available <= 0 %>
19+
<% disabled_message = "HCB transfers disabled" if sending && !policy(event).create_transfer? %>
20+
<div data-id="<%= event.public_id %>" data-slug="<%= event.slug %>" data-name="<%= event.name %>" data-organization-select-target="organization" style="<%= i > 50 ? "display: none" : "" %>" data-index="<%= i %>">
21+
<button <%= "disabled" if disabled_message %> aria-label="<%= disabled_message %>" class="<%= "tooltipped tooltipped--i" if disabled_message %> text-[length:inherit] border-none bg-[transparent] w-full flex justify-between p-2 cursor-pointer disabled:cursor-default hover:bg-smoke hover:dark:bg-darkless transition-colors duration-150">
22+
<div class="text-left"><%= event.name %></div>
23+
<div class="text-muted pl-2">
24+
<% if current_user.admin? %>
25+
<%= event.id %>
26+
<% else %>
27+
<%= turbo_frame_tag "event_balance_#{event.public_id}", src: event_async_balance_path(event, symbol: true), data: { turbo_permanent: true, controller: "cached-frame", action: "turbo:frame-render->cached-frame#cache" }, loading: :lazy do %>
28+
<strong>-</strong>
29+
<% end %>
30+
<% end %>
31+
</div>
32+
</button>
33+
<hr class="my-0">
34+
</div>
35+
<% end %>
36+
<% if receiving && allow_custom_events %>
37+
<div data-id="other" data-organization-select-target="other">
38+
<button class="text-[length:inherit] border-none bg-[transparent] w-full flex justify-between p-2 cursor-pointer disabled:cursor-default hover:bg-smoke hover:dark:bg-darkless transition-colors duration-150">
39+
<div class="text-left other-name"></div>
40+
<div></div>
41+
</button>
42+
<hr class="my-0">
43+
</div>
44+
<% end %>
45+
</div>
46+
</div>
47+
</div>
48+
</div>
49+
50+
</div>

0 commit comments

Comments
 (0)