Skip to content

Commit 27c4d13

Browse files
committed
feat: Implement person-specific checklist item completion functionality
- Add a new Stimulus controller for managing person checklist item states. - Update the Checklist model to include positioned checklist items. - Modify the PersonChecklistItem model to remove unnecessary protection. - Enhance the ChecklistPolicy to include completion status permissions. - Revamp checklist item view to support person-specific toggling and display. - Create new partials for checklist item lists and contents for better Turbo integration. - Add edit view for checklist items. - Update checklist show view to incorporate new checklist item rendering logic. - Extend routes to support person checklist item endpoints. - Implement feature specs for person checklist item completion and reordering. - Add request specs for JSON responses related to person checklist items.
1 parent 00cdbff commit 27c4d13

File tree

21 files changed

+1122
-67
lines changed

21 files changed

+1122
-67
lines changed
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
@use 'theme' as *;
2+
3+
/* Gentle cross-fade for checklist list updates to reduce visual flicker */
4+
.bt-list-fade {
5+
position: relative;
6+
}
7+
.bt-list-fade > ul {
8+
/* Do not fade the list on update — keep items fully visible. */
9+
/* Only allow transform transitions when necessary for small motion hints. */
10+
transition: transform 120ms ease-in-out;
11+
will-change: transform;
12+
}
13+
.bt-list-fade.is-updating > ul {
14+
/* No opacity change: keep list visible during updates. */
15+
opacity: 1;
16+
transform: none;
17+
}
18+
19+
/* Highlight a moved item briefly when inserted */
20+
@keyframes bt-move-highlight {
21+
0% { background-color: rgba(255, 255, 0, 0.95); }
22+
40% { background-color: rgba(255, 255, 0, 0.6); }
23+
100% { background-color: transparent; }
24+
}
25+
26+
.moved-item {
27+
animation: bt-move-highlight 900ms ease forwards;
28+
}
29+
30+
/* Smooth enable/disable transitions for move buttons */
31+
.btn.keyboard-move-up,
32+
.btn.keyboard-move-down,
33+
.keyboard-move-up,
34+
.keyboard-move-down {
35+
transition: opacity 180ms ease, transform 180ms ease;
36+
}
37+
.disabled {
38+
opacity: 0.45 !important;
39+
transform: translateY(0);
40+
}
41+
42+
/* Drag handle styling */
43+
.drag-handle {
44+
display: inline-flex;
45+
align-items: center;
46+
justify-content: center;
47+
width: 2rem;
48+
height: 2rem;
49+
color: $text-opposite-theme-color;
50+
cursor: grab;
51+
}
52+
.drag-handle:active { cursor: grabbing }
53+
.drag-handle i { font-size: 0.9rem }
54+
55+
/* Focus style for keyboard discoverability */
56+
.drag-handle:focus {
57+
outline: 2px solid rgba(0,0,0,0.12);
58+
outline-offset: 2px;
59+
border-radius: 4px;
60+
}
61+
62+
/* Cloned drag image styling: visually match the list item, but don't intercept pointer events */
63+
.bt-drag-image {
64+
pointer-events: none;
65+
box-shadow: 0 6px 18px rgba(0,0,0,0.12);
66+
opacity: 0.98;
67+
background: white;
68+
}
69+
70+
/* Visual cue for the list item currently being dragged */
71+
.dragging {
72+
opacity: 0.85;
73+
transform: scale(0.995);
74+
box-shadow: 0 10px 28px rgba(0,0,0,0.14);
75+
transition: box-shadow 120ms ease, transform 120ms ease, opacity 120ms ease;
76+
}
77+
78+
/* Drop indicators: insertion line before/after and a subtle highlight for target item */
79+
.bt-drop-before::before,
80+
.bt-drop-after::after {
81+
/* Draw a clear insertion line near the top or bottom of the target item */
82+
content: '';
83+
position: absolute;
84+
left: 0.5rem;
85+
right: 0.5rem;
86+
height: 0;
87+
pointer-events: none;
88+
border-top: 2px solid rgba(0, 123, 255, 0.9); /* high-contrast insertion line */
89+
box-shadow: 0 1px 0 rgba(0,0,0,0.06);
90+
}
91+
.bt-drop-before::before { top: 0.25rem; }
92+
.bt-drop-after::after { bottom: 0.25rem; }
93+
.bt-drop-before,
94+
.bt-drop-after {
95+
/* Remove full-row tint; keep a very subtle background so target is still readable */
96+
transition: background-color 120ms ease;
97+
background-color: transparent;
98+
position: relative; /* ensure pseudo-element is positioned relative to LI */
99+
}
100+
101+
/* Disable pointer cursor and interactions for checklist-checkboxes that are not actionable */
102+
.checklist-checkbox[aria-disabled="true"],
103+
.checklist-checkbox[tabindex="-1"] {
104+
cursor: default !important;
105+
/* Allow pointer events so hover/tooltips still work; click handlers are
106+
not present when aria-disabled is true, so disabling clicks is handled
107+
by markup rather than pointer-events. */
108+
pointer-events: auto;
109+
opacity: 0.9;
110+
}
111+
112+
/* Visual treatment for unauthenticated / non-actionable checklist items */
113+
.list-group-item .checklist-checkbox[aria-disabled="true"] {
114+
/* make the checkbox area look subdued */
115+
opacity: 0.9;
116+
position: relative;
117+
}
118+
.list-group-item .checklist-checkbox[aria-disabled="true"]::after {
119+
/* small lock glyph to indicate action is restricted */
120+
content: '\1F512'; /* Unicode lock glyph via codepoint */
121+
display: inline-block;
122+
position: absolute;
123+
left: 2.25rem;
124+
top: 50%;
125+
transform: translateY(-50%);
126+
font-size: 0.85rem;
127+
color: rgba(0,0,0,0.45);
128+
pointer-events: none;
129+
}
130+
.list-group-item[data-person-toggle="false"] {
131+
/* slightly mute entire row to indicate read-only state for this viewer */
132+
opacity: 0.95;
133+
}
134+
.list-group-item[data-person-toggle="false"] .fa-stack-2x {
135+
/* dim the avatar / icon for read-only viewers */
136+
opacity: 0.55;
137+
}
138+
.list-group-item[data-person-toggle="false"] .text-muted {
139+
color: rgba(0,0,0,0.45) !important;
140+
}

app/assets/stylesheets/better_together/application.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
@use 'devise';
2424
@use 'font-awesome';
2525
@use 'actiontext';
26+
@use 'checklist_transitions';
2627
@use 'contact_details';
2728
@use 'content_blocks';
2829
@use 'conversations';

app/controllers/better_together/checklist_items_controller.rb

Lines changed: 118 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
module BetterTogether
44
class ChecklistItemsController < FriendlyResourceController # rubocop:todo Style/Documentation, Metrics/ClassLength
55
before_action :set_checklist
6-
before_action :checklist_item, only: %i[show edit update destroy]
6+
before_action :checklist_item, only: %i[show edit update destroy position]
77

88
helper_method :new_checklist_item
99

@@ -13,7 +13,9 @@ def create # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
1313
authorize @checklist_item
1414
respond_to do |format|
1515
if @checklist_item.save
16-
format.html { redirect_to request.referer || checklist_path(@checklist), notice: t('flash.generic.created') }
16+
format.html do
17+
redirect_to request.referer || checklist_path(@checklist), notice: t('flash.generic.created')
18+
end
1719
format.turbo_stream
1820
else
1921
format.html do
@@ -35,7 +37,9 @@ def update # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
3537
authorize @checklist_item
3638
respond_to do |format|
3739
if @checklist_item.update(resource_params)
38-
format.html { redirect_to request.referer || checklist_path(@checklist), notice: t('flash.generic.updated') }
40+
format.html do
41+
redirect_to request.referer || checklist_path(@checklist), notice: t('flash.generic.updated')
42+
end
3943
format.turbo_stream
4044
else
4145
format.html do
@@ -65,7 +69,8 @@ def destroy
6569
end
6670

6771
def position # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
68-
authorize @checklist_item
72+
# Reordering affects the checklist as a whole; require permission to update the parent
73+
authorize @checklist, :update?
6974

7075
direction = params[:direction]
7176
sibling = if direction == 'up'
@@ -87,10 +92,27 @@ def position # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
8792
respond_to do |format|
8893
format.html { redirect_to request.referer || checklist_path(@checklist), notice: t('flash.generic.updated') }
8994
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-
}
95+
# Move the LI node: remove the moved element and insert before/after the sibling
96+
begin
97+
a = @checklist_item
98+
b = sibling
99+
streams = []
100+
streams << turbo_stream.remove(helpers.dom_id(a))
101+
102+
# If direction is up, insert before sibling; if down, insert after sibling
103+
if direction == 'up'
104+
streams << turbo_stream.before(helpers.dom_id(b), partial: 'better_together/checklist_items/checklist_item', locals: { checklist_item: a, checklist: @checklist, moved: true })
105+
else
106+
streams << turbo_stream.after(helpers.dom_id(b), partial: 'better_together/checklist_items/checklist_item', locals: { checklist_item: a, checklist: @checklist, moved: true })
107+
end
108+
109+
render turbo_stream: streams
110+
rescue StandardError
111+
# Fallback: update only the inner list contents
112+
render turbo_stream: turbo_stream.update("#{helpers.dom_id(@checklist, :checklist_items)}",
113+
partial: 'better_together/checklist_items/list_contents',
114+
locals: { checklist: @checklist })
115+
end
94116
end
95117
end
96118
end
@@ -104,6 +126,9 @@ def reorder # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
104126

105127
klass = resource_class
106128

129+
# Capture previous order before we update positions so we can compute a minimal DOM update
130+
previous_order = @checklist.checklist_items.order(:position).pluck(:id)
131+
107132
klass.transaction do
108133
ids.each_with_index do |id, idx|
109134
item = klass.find_by(id: id, checklist: @checklist)
@@ -116,10 +141,60 @@ def reorder # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
116141
respond_to do |format|
117142
format.json { head :no_content }
118143
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-
}
144+
# Try a minimal DOM update: if exactly one item moved, remove it and insert before/after the neighbor.
145+
begin
146+
ordered = params[:ordered_ids].map(&:to_i)
147+
# previous_order holds the order before we updated positions
148+
current_before = previous_order
149+
150+
# If nothing changed, no content
151+
if ordered == current_before
152+
head :no_content and return
153+
end
154+
155+
# Detect single moved id (difference between arrays)
156+
moved = (ordered - current_before)
157+
removed = (current_before - ordered)
158+
159+
if moved.size == 1 && removed.size == 1
160+
moved_id = moved.first
161+
moved_item = @checklist.checklist_items.find_by(id: moved_id)
162+
# Safety: if item not found, fallback
163+
unless moved_item
164+
raise 'moved-missing'
165+
end
166+
167+
# Where did it land?
168+
new_index = ordered.index(moved_id)
169+
170+
streams = []
171+
# Remove original node first
172+
streams << turbo_stream.remove(helpers.dom_id(moved_item))
173+
174+
# Append after the next element (neighbor at new_index + 1)
175+
neighbor_id = ordered[new_index + 1] if new_index
176+
if neighbor_id
177+
neighbor = @checklist.checklist_items.find_by(id: neighbor_id)
178+
if neighbor
179+
streams << turbo_stream.after(helpers.dom_id(neighbor), partial: 'better_together/checklist_items/checklist_item', locals: { checklist_item: moved_item, checklist: @checklist, moved: true })
180+
render turbo_stream: streams and return
181+
end
182+
end
183+
184+
# If neighbor not found (moved to end), append to the UL
185+
streams << turbo_stream.append("#{helpers.dom_id(@checklist, :checklist_items)} ul", partial: 'better_together/checklist_items/checklist_item', locals: { checklist_item: moved_item, checklist: @checklist, moved: true })
186+
render turbo_stream: streams and return
187+
end
188+
189+
# Fallback: update inner contents for complex reorders
190+
render turbo_stream: turbo_stream.update("#{helpers.dom_id(@checklist, :checklist_items)}",
191+
partial: 'better_together/checklist_items/list_contents',
192+
locals: { checklist: @checklist })
193+
rescue StandardError
194+
render turbo_stream: turbo_stream.update("#{helpers.dom_id(@checklist, :checklist_items)}",
195+
partial: 'better_together/checklist_items/list_contents',
196+
locals: { checklist: @checklist })
197+
end
123198
end
124199
end
125200
end
@@ -128,14 +203,37 @@ def reorder # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
128203

129204
def set_checklist
130205
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
206+
207+
@checklist = nil
208+
if key.present?
209+
# Try direct id/identifier lookup first (fast)
210+
@checklist = BetterTogether::Checklist.where(id: key).or(BetterTogether::Checklist.where(identifier: key)).first
211+
212+
# Fallbacks to mirror FriendlyResourceController behaviour: try translated slug lookups
213+
if @checklist.nil?
214+
begin
215+
# Try Mobility translation lookup across locales
216+
translation = Mobility::Backends::ActiveRecord::KeyValue::StringTranslation.where(
217+
translatable_type: 'BetterTogether::Checklist',
218+
key: 'slug',
219+
value: key
220+
).includes(:translatable).last
221+
222+
@checklist ||= translation&.translatable
223+
rescue StandardError
224+
# ignore DB/translation lookup errors and continue to friendly_id fallback
225+
end
226+
end
227+
228+
if @checklist.nil?
229+
begin
230+
@checklist = BetterTogether::Checklist.friendly.find(key)
231+
rescue StandardError
232+
@checklist ||= BetterTogether::Checklist.find_by(id: key)
233+
end
234+
end
235+
end
236+
139237
raise ActiveRecord::RecordNotFound unless @checklist
140238
end
141239

app/controllers/better_together/checklists_controller.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,23 @@ def create
88
@checklist.creator = helpers.current_person if @checklist.respond_to?(:creator=)
99

1010
if @checklist.save
11-
redirect_to @checklist, notice: t('flash.generic.created')
11+
redirect_to @checklist, notice: t('flash.generic.created', resource: t('resources.checklist'))
1212
else
1313
render :new, status: :unprocessable_entity
1414
end
1515
end
1616

17+
def completion_status
18+
authorize resource_instance
19+
person = current_user&.person
20+
total = resource_instance.checklist_items.count
21+
completed = 0
22+
23+
completed = resource_instance.person_checklist_items.where(person:).where.not(completed_at: nil).count if person
24+
25+
render json: { total: total, completed: completed, complete: total.positive? && completed >= total }
26+
end
27+
1728
private
1829

1930
def resource_class

0 commit comments

Comments
 (0)