Skip to content

Commit 6d3d2c3

Browse files
committed
Add checklist and checklist item management features
- Implement ChecklistPolicy and ChecklistItemPolicy for authorization. - Create views for checklists including index, show, new, edit, and form partials. - Add routes for checklists and nested checklist items. - Create migrations for checklists and checklist items with necessary fields. - Add factories for checklist and checklist items for testing. - Implement request specs for checklist CRUD operations and authorization. - Update locale files for checklist-related translations.
1 parent abeb415 commit 6d3d2c3

27 files changed

+901
-2
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# frozen_string_literal: true
2+
3+
module BetterTogether
4+
class ChecklistItemsController < FriendlyResourceController # rubocop:todo Style/Documentation
5+
before_action :set_checklist
6+
before_action :checklist_item, only: %i[show edit update destroy]
7+
8+
helper_method :new_checklist_item
9+
10+
def create # rubocop:todo Metrics/AbcSize
11+
@checklist_item = new_checklist_item
12+
@checklist_item.assign_attributes(resource_params)
13+
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
20+
end
21+
end
22+
23+
def update
24+
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
31+
end
32+
end
33+
34+
def destroy
35+
authorize @checklist_item
36+
37+
@checklist_item.destroy
38+
redirect_to request.referer || checklist_path(@checklist), notice: t('flash.generic.deleted')
39+
end
40+
41+
private
42+
43+
def set_checklist
44+
@checklist = BetterTogether::Checklist.find(params[:checklist_id] || params[:id])
45+
end
46+
47+
def checklist_item
48+
@checklist_item = set_resource_instance
49+
end
50+
51+
def new_checklist_item
52+
@checklist.checklist_items.new
53+
end
54+
55+
def resource_class
56+
::BetterTogether::ChecklistItem
57+
end
58+
59+
def resource_collection
60+
resource_class.where(checklist: @checklist)
61+
end
62+
end
63+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
module BetterTogether
4+
class ChecklistsController < FriendlyResourceController # rubocop:todo Style/Documentation
5+
def create
6+
@checklist = resource_class.new(resource_params)
7+
authorize @checklist
8+
@checklist.creator = helpers.current_person if @checklist.respond_to?(:creator=)
9+
10+
if @checklist.save
11+
redirect_to @checklist, notice: t('flash.generic.created')
12+
else
13+
render :new, status: :unprocessable_entity
14+
end
15+
end
16+
17+
private
18+
19+
def resource_class
20+
::BetterTogether::Checklist
21+
end
22+
end
23+
end
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
3+
module BetterTogether
4+
class Checklist < ApplicationRecord # rubocop:todo Style/Documentation
5+
include Identifier
6+
include Creatable
7+
include FriendlySlug
8+
include Protected
9+
include Privacy
10+
11+
has_many :checklist_items, class_name: '::BetterTogether::ChecklistItem', dependent: :destroy
12+
has_many :person_checklist_items, class_name: '::BetterTogether::PersonChecklistItem', dependent: :destroy
13+
14+
translates :title, type: :string
15+
16+
slugged :title
17+
18+
validates :title, presence: true
19+
20+
# Returns checklist items along with per-person completions for a given person
21+
def items_with_progress_for(person)
22+
checklist_items.includes(:translations).map do |item|
23+
{
24+
item: item,
25+
done: item.done_for?(person),
26+
completion_record: BetterTogether::PersonChecklistItem.find_by(person:, checklist: self,
27+
checklist_item: item)
28+
}
29+
end
30+
end
31+
32+
# Percentage of items completed for a given person (0..100)
33+
def completion_percentage_for(person)
34+
total = checklist_items.count
35+
return 0 if total.zero?
36+
37+
completed = person_checklist_items.where(person:, done: true).count
38+
((completed.to_f / total) * 100).round
39+
end
40+
41+
def to_param
42+
slug
43+
end
44+
end
45+
end
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# frozen_string_literal: true
2+
3+
module BetterTogether
4+
# An item belonging to a Checklist. Translated label and description.
5+
class ChecklistItem < ApplicationRecord
6+
include Identifier
7+
include Creatable
8+
include FriendlySlug
9+
include Positioned
10+
include Protected
11+
include Privacy
12+
13+
belongs_to :checklist, class_name: '::BetterTogether::Checklist', inverse_of: :checklist_items
14+
15+
translates :label, type: :string
16+
translates :description, backend: :action_text
17+
18+
slugged :label
19+
20+
validates :label, presence: true
21+
22+
# Per-person completion helpers
23+
def done_for?(person)
24+
return false unless person
25+
26+
BetterTogether::PersonChecklistItem.completed.exists?(person:, checklist: checklist, checklist_item: self)
27+
end
28+
29+
def completion_record_for(person)
30+
BetterTogether::PersonChecklistItem.find_by(person:, checklist: checklist, checklist_item: self)
31+
end
32+
33+
def self.permitted_attributes(id: false, destroy: false)
34+
super + %i[checklist_id]
35+
end
36+
37+
def to_s
38+
label
39+
end
40+
end
41+
end
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# frozen_string_literal: true
2+
3+
module BetterTogether
4+
class PersonChecklistItem < ApplicationRecord # rubocop:todo Style/Documentation
5+
include Creatable
6+
include Protected
7+
8+
belongs_to :person, class_name: 'BetterTogether::Person'
9+
belongs_to :checklist, class_name: 'BetterTogether::Checklist'
10+
belongs_to :checklist_item, class_name: 'BetterTogether::ChecklistItem'
11+
12+
validates :person, :checklist, :checklist_item, presence: true
13+
14+
before_save :enforce_directional_progression, if: :setting_completed_at?
15+
16+
def mark_done!(completed_at: Time.zone.now)
17+
update!(completed_at: completed_at)
18+
end
19+
20+
def mark_undone!
21+
update!(completed_at: nil)
22+
end
23+
24+
def done?
25+
completed_at.present?
26+
end
27+
28+
scope :completed, -> { where.not(completed_at: nil) }
29+
scope :pending, -> { where(completed_at: nil) }
30+
31+
private
32+
33+
def setting_completed_at?
34+
completed_at_changed? && completed_at.present?
35+
end
36+
37+
def enforce_directional_progression # rubocop:todo Metrics/AbcSize
38+
return unless checklist&.directional
39+
40+
# Find any items with position less than this item that are not completed for this person
41+
earlier_items = checklist.checklist_items.where('position < ?', checklist_item.position)
42+
43+
return if earlier_items.none?
44+
45+
incomplete = earlier_items.any? do |item|
46+
!BetterTogether::PersonChecklistItem.where.not(completed_at: nil).exists?(person:, checklist:,
47+
checklist_item: item)
48+
end
49+
50+
return unless incomplete
51+
52+
errors.add(:completed_at, I18n.t('errors.models.person_checklist_item.directional_incomplete'))
53+
throw(:abort)
54+
end
55+
end
56+
end
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# frozen_string_literal: true
2+
3+
module BetterTogether
4+
class ChecklistItemPolicy < ApplicationPolicy # rubocop:todo Style/Documentation
5+
def show?
6+
# If parent checklist is public or user can update checklist
7+
record.checklist.privacy_public? || ChecklistPolicy.new(user, record.checklist).update?
8+
end
9+
10+
def create?
11+
ChecklistPolicy.new(user, record.checklist).update?
12+
end
13+
14+
def update?
15+
ChecklistPolicy.new(user, record.checklist).update?
16+
end
17+
18+
def destroy?
19+
ChecklistPolicy.new(user, record.checklist).destroy?
20+
end
21+
22+
class Scope < ApplicationPolicy::Scope # rubocop:todo Style/Documentation
23+
def resolve
24+
scope.with_translations
25+
end
26+
end
27+
end
28+
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# frozen_string_literal: true
2+
3+
module BetterTogether
4+
class ChecklistPolicy < ApplicationPolicy # rubocop:todo Style/Documentation
5+
def show?
6+
# Allow viewing public checklists to everyone, otherwise fall back to update permissions
7+
record.privacy_public? || update?
8+
end
9+
10+
def index?
11+
# Let policy_scope handle visibility; index access is allowed (scope filters public/private)
12+
true
13+
end
14+
15+
def create?
16+
permitted_to?('manage_platform')
17+
end
18+
19+
def update?
20+
permitted_to?('manage_platform') || (agent.present? && record.creator == agent)
21+
end
22+
23+
def destroy?
24+
permitted_to?('manage_platform') && !record.protected?
25+
end
26+
27+
class Scope < ApplicationPolicy::Scope # rubocop:todo Style/Documentation
28+
def resolve
29+
scope.with_translations
30+
end
31+
end
32+
end
33+
end
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<!-- app/views/better_together/checklists/_checklist.html.erb -->
2+
3+
<li class="list-group-item d-flex justify-content-between align-items-center" id="<%= dom_id(checklist) %>">
4+
<div>
5+
<%= link_to checklist.title, better_together.checklist_path(checklist, locale: I18n.locale), class: 'h6 mb-0' %>
6+
<% if checklist.creator.present? %>
7+
<small class="text-muted ms-2">&middot; <%= checklist.creator.to_s %></small>
8+
<% end %>
9+
</div>
10+
11+
<div class="text-end">
12+
<%= render 'shared/resource_toolbar',
13+
back_to_list_path: better_together.checklists_path(locale: I18n.locale),
14+
edit_path: (policy(checklist).update? ? better_together.edit_checklist_path(checklist, locale: I18n.locale) : nil),
15+
destroy_path: (policy(checklist).destroy? ? better_together.checklist_path(checklist, locale: I18n.locale) : nil) do %>
16+
<span class="badge bg-secondary"><%= checklist.privacy %></span>
17+
<% end %>
18+
</div>
19+
</li>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<!-- app/views/better_together/checklists/_form.html.erb -->
2+
3+
<%= content_tag :div, id: 'checklist_form' do %>
4+
<%= form_with(model: [@checklist], local: true, class: 'form') do |form| %>
5+
<% content_for :action_toolbar do %>
6+
<div class="btn-toolbar mb-3" role="toolbar" aria-label="Toolbar with button groups">
7+
<div class="btn-group me-2" role="group">
8+
<%= link_to t('globals.back_to_list'), better_together.checklists_path(locale: I18n.locale), class: 'btn btn-secondary' %>
9+
</div>
10+
<div class="btn-group me-2" role="group">
11+
<%= form.submit class: 'btn btn-primary' %>
12+
</div>
13+
</div>
14+
<% end %>
15+
16+
<%= yield :action_toolbar %>
17+
18+
<div id="form_errors">
19+
<% if @checklist.errors.any? %>
20+
<div class="alert alert-danger">
21+
<h4><%= pluralize(@checklist.errors.count, "error") %> prohibited this checklist from being saved:</h4>
22+
<ul>
23+
<% @checklist.errors.full_messages.each do |message| %>
24+
<li><%= message %></li>
25+
<% end %>
26+
</ul>
27+
</div>
28+
<% end %>
29+
</div>
30+
31+
<div class="mb-3">
32+
<%= render partial: 'better_together/shared/translated_string_field', locals: { model: @checklist, form: form, attribute: 'title' } %>
33+
</div>
34+
35+
<div class="mb-3">
36+
<%= form.label :privacy %>
37+
<%= form.select :privacy, options_for_select([['Public', 'public'], ['Private', 'private']], @checklist.privacy), {}, class: 'form-select' %>
38+
</div>
39+
40+
<%= yield :action_toolbar %>
41+
<% end %>
42+
<% end %>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<!-- app/views/better_together/checklists/edit.html.erb -->
2+
3+
<% content_for :page_title do %>
4+
<%= t('better_together.checklists.edit.title', default: 'Edit Checklist') %>
5+
<% end %>
6+
7+
<div class="container my-3">
8+
<h1 class="mb-3"><%= t('better_together.checklists.edit.title', default: 'Edit Checklist') %></h1>
9+
10+
<%= render partial: 'form' %>
11+
</div>

0 commit comments

Comments
 (0)