Skip to content

Commit 00cdbff

Browse files
committed
Implement checklist item reordering with Turbo Streams and add associated tests
1 parent 6d3d2c3 commit 00cdbff

File tree

15 files changed

+420
-20
lines changed

15 files changed

+420
-20
lines changed

app/controllers/better_together/checklist_items_controller.rb

Lines changed: 112 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,142 @@
11
# frozen_string_literal: true
22

33
module BetterTogether
4-
class ChecklistItemsController < FriendlyResourceController # rubocop:todo Style/Documentation
4+
class ChecklistItemsController < FriendlyResourceController # rubocop:todo Style/Documentation, Metrics/ClassLength
55
before_action :set_checklist
66
before_action :checklist_item, only: %i[show edit update destroy]
77

88
helper_method :new_checklist_item
99

10-
def create # rubocop:todo Metrics/AbcSize
10+
def create # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
1111
@checklist_item = new_checklist_item
1212
@checklist_item.assign_attributes(resource_params)
1313
authorize @checklist_item
14-
15-
if @checklist_item.save
16-
redirect_to request.referer || checklist_path(@checklist), notice: t('flash.generic.created')
17-
else
18-
redirect_to request.referer || checklist_path(@checklist),
19-
alert: @checklist_item.errors.full_messages.to_sentence
14+
respond_to do |format|
15+
if @checklist_item.save
16+
format.html { redirect_to request.referer || checklist_path(@checklist), notice: t('flash.generic.created') }
17+
format.turbo_stream
18+
else
19+
format.html do
20+
redirect_to request.referer || checklist_path(@checklist),
21+
alert: @checklist_item.errors.full_messages.to_sentence
22+
end
23+
format.turbo_stream do
24+
render turbo_stream: turbo_stream.replace(dom_id(new_checklist_item)) {
25+
render partial: 'form',
26+
locals: { form_object: @checklist_item,
27+
form_url: better_together.checklist_checklist_items_path(@checklist) }
28+
}
29+
end
30+
end
2031
end
2132
end
2233

23-
def update
34+
def update # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
2435
authorize @checklist_item
25-
26-
if @checklist_item.update(resource_params)
27-
redirect_to request.referer || checklist_path(@checklist), notice: t('flash.generic.updated')
28-
else
29-
redirect_to request.referer || checklist_path(@checklist),
30-
alert: @checklist_item.errors.full_messages.to_sentence
36+
respond_to do |format|
37+
if @checklist_item.update(resource_params)
38+
format.html { redirect_to request.referer || checklist_path(@checklist), notice: t('flash.generic.updated') }
39+
format.turbo_stream
40+
else
41+
format.html do
42+
redirect_to request.referer || checklist_path(@checklist),
43+
alert: @checklist_item.errors.full_messages.to_sentence
44+
end
45+
format.turbo_stream do
46+
render turbo_stream: turbo_stream.replace(dom_id(@checklist_item)) {
47+
render partial: 'form',
48+
locals: { checklist_item: @checklist_item,
49+
form_url: better_together.checklist_checklist_item_path(@checklist,
50+
@checklist_item) }
51+
}
52+
end
53+
end
3154
end
3255
end
3356

3457
def destroy
3558
authorize @checklist_item
3659

3760
@checklist_item.destroy
38-
redirect_to request.referer || checklist_path(@checklist), notice: t('flash.generic.deleted')
61+
respond_to do |format|
62+
format.html { redirect_to request.referer || checklist_path(@checklist), notice: t('flash.generic.deleted') }
63+
format.turbo_stream
64+
end
65+
end
66+
67+
def position # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
68+
authorize @checklist_item
69+
70+
direction = params[:direction]
71+
sibling = if direction == 'up'
72+
resource_class.where(checklist: @checklist).where('position < ?',
73+
@checklist_item.position).order(position: :desc).first # rubocop:disable Layout/LineLength
74+
elsif direction == 'down'
75+
resource_class.where(checklist: @checklist).where('position > ?',
76+
@checklist_item.position).order(position: :asc).first # rubocop:disable Layout/LineLength
77+
end
78+
79+
if sibling
80+
ActiveRecord::Base.transaction do
81+
a_pos = @checklist_item.position
82+
@checklist_item.update!(position: sibling.position)
83+
sibling.update!(position: a_pos)
84+
end
85+
end
86+
87+
respond_to do |format|
88+
format.html { redirect_to request.referer || checklist_path(@checklist), notice: t('flash.generic.updated') }
89+
format.turbo_stream do
90+
render turbo_stream: turbo_stream.replace(dom_id(@checklist, :checklist_items)) {
91+
render partial: 'better_together/checklist_items/checklist_item',
92+
collection: @checklist.checklist_items.with_translations, as: :checklist_item
93+
}
94+
end
95+
end
96+
end
97+
98+
def reorder # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
99+
# Reordering affects the checklist as a whole; require permission to update the parent
100+
authorize @checklist, :update?
101+
102+
ids = params[:ordered_ids] || []
103+
return head :bad_request unless ids.is_a?(Array)
104+
105+
klass = resource_class
106+
107+
klass.transaction do
108+
ids.each_with_index do |id, idx|
109+
item = klass.find_by(id: id, checklist: @checklist)
110+
next unless item
111+
112+
item.update!(position: idx)
113+
end
114+
end
115+
116+
respond_to do |format|
117+
format.json { head :no_content }
118+
format.turbo_stream do
119+
render turbo_stream: turbo_stream.replace(dom_id(@checklist, :checklist_items)) {
120+
render partial: 'better_together/checklist_items/checklist_item',
121+
collection: @checklist.checklist_items.with_translations, as: :checklist_item
122+
}
123+
end
124+
end
39125
end
40126

41127
private
42128

43129
def set_checklist
44-
@checklist = BetterTogether::Checklist.find(params[:checklist_id] || params[:id])
130+
key = params[:checklist_id] || params[:id]
131+
@checklist = if key.nil?
132+
nil
133+
else
134+
# The checklists table doesn't have a direct `slug` column in this schema
135+
# (friendly id slugs are stored in the `friendly_id_slugs` table), so avoid
136+
# querying `slug` directly. Lookup by id or identifier instead.
137+
BetterTogether::Checklist.where(id: key).or(BetterTogether::Checklist.where(identifier: key)).first
138+
end
139+
raise ActiveRecord::RecordNotFound unless @checklist
45140
end
46141

47142
def checklist_item
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { Controller } from '@hotwired/stimulus'
2+
3+
// This controller is intentionally minimal; Turbo Streams handle most updates.
4+
export default class extends Controller {
5+
static targets = [ 'list', 'form' ]
6+
static values = { checklistId: String }
7+
8+
connect() {
9+
// Accessible live region for announcements
10+
this.liveRegion = document.getElementById('a11y-live-region') || this.createLiveRegion()
11+
this.addKeyboardHandlers()
12+
this.addDragHandlers()
13+
}
14+
15+
createLiveRegion() {
16+
const lr = document.createElement('div')
17+
lr.id = 'a11y-live-region'
18+
lr.setAttribute('aria-live', 'polite')
19+
lr.setAttribute('aria-atomic', 'true')
20+
lr.style.position = 'absolute'
21+
lr.style.left = '-9999px'
22+
lr.style.width = '1px'
23+
lr.style.height = '1px'
24+
document.body.appendChild(lr)
25+
return lr
26+
}
27+
28+
focusForm(event) {
29+
// Called via data-action on the appended stream node
30+
// Give DOM a tick for Turbo to render the new nodes, then focus
31+
setTimeout(() => {
32+
const f = this.hasFormTarget ? this.formTarget.querySelector('form') : null
33+
if (f) {
34+
f.querySelector('input, textarea')?.focus()
35+
}
36+
37+
// Announce success if provided
38+
const elem = event.currentTarget || event.target
39+
const announcement = elem?.dataset?.betterTogetherChecklistItemsAnnouncement
40+
if (announcement) this.liveRegion.textContent = announcement
41+
}, 50)
42+
}
43+
44+
addKeyboardHandlers() {
45+
if (!this.hasListTarget) return
46+
47+
this.listTarget.querySelectorAll('li[tabindex]').forEach((li) => {
48+
li.addEventListener('keydown', (e) => {
49+
if (e.key === 'ArrowUp' && e.ctrlKey) {
50+
e.preventDefault()
51+
li.querySelector('.keyboard-move-up')?.click()
52+
} else if (e.key === 'ArrowDown' && e.ctrlKey) {
53+
e.preventDefault()
54+
li.querySelector('.keyboard-move-down')?.click()
55+
}
56+
})
57+
})
58+
}
59+
60+
addDragHandlers() {
61+
if (!this.hasListTarget) return
62+
63+
let dragSrc = null
64+
const list = this.listTarget
65+
66+
list.querySelectorAll('li[draggable]').forEach((el) => {
67+
el.addEventListener('dragstart', (e) => {
68+
dragSrc = el
69+
e.dataTransfer.effectAllowed = 'move'
70+
})
71+
72+
el.addEventListener('dragover', (e) => {
73+
e.preventDefault()
74+
e.dataTransfer.dropEffect = 'move'
75+
})
76+
77+
el.addEventListener('drop', (e) => {
78+
e.preventDefault()
79+
if (!dragSrc || dragSrc === el) return
80+
// Insert dragSrc before or after target depending on position
81+
const rect = el.getBoundingClientRect()
82+
const before = (e.clientY - rect.top) < (rect.height / 2)
83+
if (before) el.parentNode.insertBefore(dragSrc, el)
84+
else el.parentNode.insertBefore(dragSrc, el.nextSibling)
85+
86+
this.postReorder()
87+
})
88+
})
89+
}
90+
91+
postReorder() {
92+
const ids = Array.from(this.listTarget.querySelectorAll('li[data-id]')).map((li) => li.dataset.id)
93+
const url = `/` + I18n.locale + `/${BetterTogether.route_scope_path}/checklists/${this.checklistIdValue}/checklist_items/reorder`
94+
fetch(url, {
95+
method: 'PATCH',
96+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content },
97+
body: JSON.stringify({ ordered_ids: ids })
98+
}).then(() => {
99+
// Optionally announce completion
100+
this.liveRegion.textContent = 'Items reordered'
101+
})
102+
}
103+
}

app/policies/better_together/checklist_item_policy.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ def destroy?
1919
ChecklistPolicy.new(user, record.checklist).destroy?
2020
end
2121

22+
# Permission for bulk reorder endpoint (collection-level)
23+
def reorder?
24+
ChecklistPolicy.new(user, record.checklist).update?
25+
end
26+
2227
class Scope < ApplicationPolicy::Scope # rubocop:todo Style/Documentation
2328
def resolve
2429
scope.with_translations
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<%# app/views/better_together/checklist_items/_checklist_item.html.erb %>
2+
<%= turbo_frame_tag dom_id(checklist_item) do %>
3+
<li tabindex="0" draggable="true" data-id="<%= checklist_item.id %>" class="list-group-item d-flex justify-content-between align-items-center" id="<%= dom_id(checklist_item) %>">
4+
<div>
5+
<strong><%= checklist_item.label.presence || t('better_together.checklist_items.untitled', default: 'Untitled item') %></strong>
6+
<% if checklist_item.description.present? %>
7+
<div class="text-muted small"><%= truncate(strip_tags(checklist_item.description.to_s), length: 140) %></div>
8+
<% end %>
9+
</div>
10+
11+
<div class="text-end">
12+
<%# Move controls: up / down %>
13+
<% if policy(checklist_item).update? %>
14+
<div class="btn-group me-2" role="group" aria-label="Move item">
15+
<%= link_to '&#8593;'.html_safe, better_together.position_checklist_checklist_item_path(checklist_item.checklist, checklist_item, direction: 'up', locale: I18n.locale), method: :patch, data: { turbo_frame: '_top' }, class: 'btn btn-sm btn-outline-secondary keyboard-move-up', title: t('better_together.checklist_items.move_up', default: 'Move up') %>
16+
<%= link_to '&#8595;'.html_safe, better_together.position_checklist_checklist_item_path(checklist_item.checklist, checklist_item, direction: 'down', locale: I18n.locale), method: :patch, data: { turbo_frame: '_top' }, class: 'btn btn-sm btn-outline-secondary keyboard-move-down', title: t('better_together.checklist_items.move_down', default: 'Move down') %>
17+
</div>
18+
<% end %>
19+
<% if policy(checklist_item).update? %>
20+
<%= link_to t('globals.edit'), better_together.edit_checklist_checklist_item_path(checklist_item.checklist, checklist_item, locale: I18n.locale), data: { turbo_frame: dom_id(checklist_item) }, class: 'btn btn-sm btn-outline-secondary me-2' %>
21+
<% end %>
22+
23+
<% if policy(checklist_item).destroy? %>
24+
<%= link_to t('globals.delete'), better_together.checklist_checklist_item_path(checklist_item.checklist, checklist_item, locale: I18n.locale), data: { turbo_method: :delete, turbo_confirm: t('globals.are_you_sure'), turbo_frame: '_top' }, class: 'btn btn-sm btn-outline-danger' %>
25+
<% end %>
26+
</div>
27+
</li>
28+
<% end %>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<%# app/views/better_together/checklist_items/_form.html.erb %>
2+
<%= turbo_frame_tag dom_id(form_object || checklist_item || new_checklist_item) do %>
3+
<%= form_with(model: form_object || checklist_item || new_checklist_item, url: form_url || request.path, local: false) do |f| %>
4+
<div class="mb-2">
5+
<%= f.label :label, t('better_together.checklist_items.label', default: 'Label') %>
6+
<%= f.text_field :label, class: 'form-control' %>
7+
</div>
8+
9+
<div class="mb-2">
10+
<%= f.label :description, t('better_together.checklist_items.description', default: 'Description') %>
11+
<%= f.text_area :description, class: 'form-control', rows: 3 %>
12+
</div>
13+
14+
<div class="d-flex justify-content-end">
15+
<%= f.submit t('globals.save', default: 'Save'), class: 'btn btn-primary btn-sm' %>
16+
</div>
17+
<% end %>
18+
<% end %>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<%= turbo_stream.append dom_id(@checklist, :checklist_items) do %>
2+
<%= render partial: 'checklist_item', locals: { checklist_item: @checklist_item } %>
3+
<% end %>
4+
5+
<%= turbo_stream.append dom_id(@checklist, :streams) do %>
6+
<div data-action="turbo:before-stream-render->better_together--checklist_items#focusForm" data-better-together-checklist-items-announcement="<%= j t('better_together.checklist_items.created', default: 'Item created') %>"></div>
7+
<% end %>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<%= turbo_stream.remove dom_id(@checklist_item) %>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<%= turbo_stream.replace dom_id(@checklist_item) do %>
2+
<%= render partial: 'checklist_item', locals: { checklist_item: @checklist_item } %>
3+
<% end %>

app/views/better_together/checklists/show.html.erb

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,25 @@
1616
</div>
1717

1818
<div>
19-
<%# Placeholder: checklist items rendering is out of scope for this PR %>
20-
<p><%= t('better_together.checklists.show.no_items', default: 'No items to display') %></p>
19+
<div data-controller="better_together/checklist_items" data-better_together-checklist_items-checklist-id-value="<%= @checklist.id %>" class="card">
20+
<div class="card-body">
21+
<h5 class="mb-3"><%= t('better_together.checklists.show.items_title', default: 'Items') %></h5>
22+
23+
<div id="<%= dom_id(@checklist, :checklist_items) %>" data-better_together-checklist_items-target="list">
24+
<ul class="list-group" role="list" aria-labelledby="checklist-items-list">
25+
<%= render partial: 'better_together/checklist_items/checklist_item', collection: @checklist.checklist_items.with_translations, as: :checklist_item %>
26+
</ul>
27+
</div>
28+
29+
<% if policy(BetterTogether::ChecklistItem.new(checklist: @checklist)).create? %>
30+
<div class="mt-3" data-better_together-checklist_items-target="form">
31+
<% form_object = BetterTogether::ChecklistItem.new(checklist: @checklist) %>
32+
<%= render partial: 'better_together/checklist_items/form', locals: { form_object: form_object, form_url: better_together.checklist_checklist_items_path(@checklist, locale: I18n.locale) } %>
33+
</div>
34+
<% end %>
35+
36+
<div id="<%= dom_id(@checklist, :streams) %>" aria-hidden="true"></div>
37+
</div>
38+
</div>
2139
</div>
2240
</div>

config/locales/en.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,13 @@ en:
650650
new_btn_text: New Category
651651
save_category: Save Category
652652
view_category: View Category
653+
checklist_items:
654+
created: Item created
655+
description: Description
656+
label: Label
657+
move_down: Move down
658+
move_up: Move up
659+
untitled: Untitled item
653660
checklists:
654661
edit:
655662
title: Edit Checklist
@@ -659,6 +666,7 @@ en:
659666
new:
660667
title: New Checklist
661668
show:
669+
items_title: Items
662670
no_items: No items to display
663671
communities:
664672
index:
@@ -1824,6 +1832,7 @@ en:
18241832
published: Published
18251833
remove: Remove
18261834
resend: Resend
1835+
save: Save
18271836
sent: Sent
18281837
show: Show
18291838
tabs:

0 commit comments

Comments
 (0)