+ data-method="post"
+ data-notification-list-item
+ <%= "data-read" if read %>
+ class="group flex items-start gap-3.5 px-4 py-3.5 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500">
+
+ <% end %>
diff --git a/app/components/notification_component.rb b/app/components/notification_component.rb
index 7641538115..f3606e875d 100644
--- a/app/components/notification_component.rb
+++ b/app/components/notification_component.rb
@@ -6,8 +6,4 @@ class NotificationComponent < ViewComponent::Base
def initialize(notification:)
@notification = notification
end
-
- def muted_display
- "bg-light text-muted" if notification.read?
- end
end
diff --git a/app/components/sidebar/group_component.html.erb b/app/components/sidebar/group_component.html.erb
deleted file mode 100644
index 017193d822..0000000000
--- a/app/components/sidebar/group_component.html.erb
+++ /dev/null
@@ -1,21 +0,0 @@
-
diff --git a/app/components/sidebar/group_component.rb b/app/components/sidebar/group_component.rb
deleted file mode 100644
index db75ef20d7..0000000000
--- a/app/components/sidebar/group_component.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-class Sidebar::GroupComponent < ViewComponent::Base
- renders_many :links, Sidebar::LinkComponent
-
- # @param title [String] the title/label for the link
- # @param icon [String] the lni icon, pass just the name of the icon (ie. for lni-star --> icon: "star")
- # @param render_check [Boolean] whether or not to display the link
- def initialize(title:, icon:, render_check: true)
- @title = title
- @icon = icon
- @render_check = render_check
- @identifier = title.downcase.tr(" ", "-")
- @class = "#{@identifier} collapsed"
- end
-
- # If there are no links or all links fail their render_check, then don't render this group
- # @return [Boolean]
- def render?
- @render_check && !links.empty? && !links.select(&:render?).empty?
- end
-end
diff --git a/app/components/sidebar/link_component.html.erb b/app/components/sidebar/link_component.html.erb
deleted file mode 100644
index e1907d1c74..0000000000
--- a/app/components/sidebar/link_component.html.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-
diff --git a/app/components/sidebar/link_component.rb b/app/components/sidebar/link_component.rb
deleted file mode 100644
index d1f0f745b8..0000000000
--- a/app/components/sidebar/link_component.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-# frozen_string_literal: true
-
-class Sidebar::LinkComponent < ViewComponent::Base
- include SidebarHelper
-
- # @param title [String] the title/label for the link
- # @param path [String] the path to navigate to
- # @param icon [String] the lni icon, pass just the name of the icon (ie. for lni-star --> icon: "star")
- # @param nav_item [Boolean] whether or not the link should have the nav-item class
- # @param render_check [Boolean] whether or not to display the link
- def initialize(title:, path:, icon: nil, nav_item: true, render_check: true)
- @title = title
- @icon = icon
- @path = path
- @nav_item = nav_item
- @render_check = render_check
- end
-
- # Must be moved to this method in order to use the SidebarHelper
- def before_render
- @class = @nav_item ? "nav-item #{active_class(@path)}" : ""
- end
-
- # @return [Boolean]
- def render?
- @render_check
- end
-end
diff --git a/app/components/truncated_text_component.html.erb b/app/components/truncated_text_component.html.erb
index 22b5931c81..0cd4fe62db 100644
--- a/app/components/truncated_text_component.html.erb
+++ b/app/components/truncated_text_component.html.erb
@@ -1,7 +1,10 @@
-
+<%# Label:value detail line for the case-contact card. Body size (text-sm) with a muted
+ font-medium label and a dark regular value (design.md fact/detail-list convention), so a
+ detail line never out-weighs the card's font-semibold title. %>
+
<% if label %>
- <%= label %>:
+ <%= label %>:
<% end %>
<%= text %>
@@ -10,10 +13,11 @@
href="#"
data-truncated-text-target="hideButton"
data-action="truncated-text#toggle:prevent"
- class="d-none">[hide]
+ class="hidden font-medium text-brand-600 hover:text-brand-700">[hide]
[read more]
+ data-action="truncated-text#toggle:prevent"
+ class="font-medium text-brand-600 hover:text-brand-700">[read more]
diff --git a/app/controllers/all_casa_admins/casa_admins_controller.rb b/app/controllers/all_casa_admins/casa_admins_controller.rb
index 033b6d57b7..faff2c88e8 100644
--- a/app/controllers/all_casa_admins/casa_admins_controller.rb
+++ b/app/controllers/all_casa_admins/casa_admins_controller.rb
@@ -1,5 +1,6 @@
class AllCasaAdmins::CasaAdminsController < AllCasaAdminsController
before_action :set_casa_org
+ before_action -> { @active_nav = "organizations" }
def new
@casa_admin = CasaAdmin.new
diff --git a/app/controllers/all_casa_admins/casa_orgs_controller.rb b/app/controllers/all_casa_admins/casa_orgs_controller.rb
index 878bbfcb15..357509ef34 100644
--- a/app/controllers/all_casa_admins/casa_orgs_controller.rb
+++ b/app/controllers/all_casa_admins/casa_orgs_controller.rb
@@ -1,4 +1,6 @@
class AllCasaAdmins::CasaOrgsController < AllCasaAdminsController
+ before_action -> { @active_nav = "organizations" }
+
def show
@casa_org = CasaOrg.find(params[:id])
@casa_org_metrics = AllCasaAdmins::CasaOrgMetrics.new(@casa_org).metrics
diff --git a/app/controllers/all_casa_admins/dashboard_controller.rb b/app/controllers/all_casa_admins/dashboard_controller.rb
index 65070aaafb..ab3f25fd99 100644
--- a/app/controllers/all_casa_admins/dashboard_controller.rb
+++ b/app/controllers/all_casa_admins/dashboard_controller.rb
@@ -1,4 +1,6 @@
class AllCasaAdmins::DashboardController < AllCasaAdminsController
+ before_action -> { @active_nav = "organizations" }
+
def show
@organizations = CasaOrg.all
end
diff --git a/app/controllers/all_casa_admins/invitations_controller.rb b/app/controllers/all_casa_admins/invitations_controller.rb
new file mode 100644
index 0000000000..c65591da68
--- /dev/null
+++ b/app/controllers/all_casa_admins/invitations_controller.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+# All-CASA-admin invitations on the casa_auth shell (was the Bootstrap layouts/devise).
+class AllCasaAdmins::InvitationsController < Devise::InvitationsController
+ layout "casa_auth"
+end
diff --git a/app/controllers/all_casa_admins/metrics_controller.rb b/app/controllers/all_casa_admins/metrics_controller.rb
new file mode 100644
index 0000000000..463a574bf3
--- /dev/null
+++ b/app/controllers/all_casa_admins/metrics_controller.rb
@@ -0,0 +1,11 @@
+class AllCasaAdmins::MetricsController < AllCasaAdminsController
+ before_action -> { @active_nav = "metrics" }
+
+ def index
+ @range = MetricsReport.clamp_range(params[:range])
+ report = MetricsReport.new
+ @case_contacts = report.monthly_case_contacts(@range)
+ @active_users = report.monthly_active_users(@range)
+ @contact_heatmap = report.contact_creation_heatmap(@range)
+ end
+end
diff --git a/app/controllers/all_casa_admins/passwords_controller.rb b/app/controllers/all_casa_admins/passwords_controller.rb
new file mode 100644
index 0000000000..8255e30069
--- /dev/null
+++ b/app/controllers/all_casa_admins/passwords_controller.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+# All-CASA-admin password reset on the casa_auth shell (was the Bootstrap layouts/devise).
+class AllCasaAdmins::PasswordsController < Devise::PasswordsController
+ layout "casa_auth"
+end
diff --git a/app/controllers/all_casa_admins/patch_notes_controller.rb b/app/controllers/all_casa_admins/patch_notes_controller.rb
index 1dc35295f2..db84053280 100644
--- a/app/controllers/all_casa_admins/patch_notes_controller.rb
+++ b/app/controllers/all_casa_admins/patch_notes_controller.rb
@@ -1,4 +1,6 @@
class AllCasaAdmins::PatchNotesController < AllCasaAdminsController
+ before_action -> { @active_nav = "patch_notes" }
+
# GET /patch_notes or /patch_notes.json
def index
@patch_note_groups = PatchNoteGroup.all
diff --git a/app/controllers/all_casa_admins/sessions_controller.rb b/app/controllers/all_casa_admins/sessions_controller.rb
index 1c41a5ec44..fac114086c 100644
--- a/app/controllers/all_casa_admins/sessions_controller.rb
+++ b/app/controllers/all_casa_admins/sessions_controller.rb
@@ -2,5 +2,6 @@
class AllCasaAdmins::SessionsController < Devise::SessionsController
include Accessible
+ layout "casa_auth"
skip_before_action :check_user, only: :destroy
end
diff --git a/app/controllers/all_casa_admins_controller.rb b/app/controllers/all_casa_admins_controller.rb
index 2c544544e6..2de7a779c0 100644
--- a/app/controllers/all_casa_admins_controller.rb
+++ b/app/controllers/all_casa_admins_controller.rb
@@ -1,6 +1,8 @@
class AllCasaAdminsController < ApplicationController
+ layout "all_casa_admin"
skip_before_action :authenticate_user!
before_action :authenticate_all_casa_admin!
+ before_action -> { @active_nav ||= "profile" }
before_action :set_custom_error_heading, only: [:update_password]
after_action :reset_custom_error_heading, only: [:update_password]
skip_after_action :verify_authorized
diff --git a/app/controllers/analytics_controller.rb b/app/controllers/analytics_controller.rb
new file mode 100644
index 0000000000..eb2761ff6e
--- /dev/null
+++ b/app/controllers/analytics_controller.rb
@@ -0,0 +1,33 @@
+class AnalyticsController < ApplicationController
+ after_action :verify_authorized
+ skip_after_action :verify_policy_scoped # aggregates are org-scoped inside MetricsReport, not via a policy_scope
+
+ def index
+ authorize :analytics, :index?
+ @active_nav = "analytics"
+ @range = MetricsReport.clamp_range(params[:range])
+ report = MetricsReport.new(casa_org: current_organization)
+ @case_contacts = report.monthly_case_contacts(@range)
+ @active_users = report.monthly_active_users(@range)
+ @contact_heatmap = report.contact_creation_heatmap(@range)
+ @kpis = chapter_kpis(report)
+ render layout: "casa_app"
+ end
+
+ private
+
+ # Headline chapter numbers for the KPI cards. Reuses AdminDashboard for the org-scoped
+ # "active volunteers" and "cases needing contact" (the app's canonical 14-day definition,
+ # batched for org scale) and MetricsReport for the month-over-month contact delta.
+ def chapter_kpis(report)
+ dashboard = AdminDashboard.new(current_organization)
+ this_month = report.contacts_this_month
+ {
+ contacts_this_month: this_month,
+ contacts_delta: this_month - report.contacts_previous_month,
+ active_volunteers: dashboard.stats[:volunteers],
+ cases_needing_contact: dashboard.stats[:needs_contact],
+ followup_days: AdminDashboard::FOLLOWUP_DAYS
+ }
+ end
+end
diff --git a/app/controllers/banners_controller.rb b/app/controllers/banners_controller.rb
index 59e7f12768..9c97dbe431 100644
--- a/app/controllers/banners_controller.rb
+++ b/app/controllers/banners_controller.rb
@@ -1,4 +1,6 @@
class BannersController < ApplicationController
+ layout "casa_app"
+ before_action -> { @active_nav = "settings" }, except: %i[dismiss]
after_action :verify_authorized, except: %i[dismiss]
skip_after_action :verify_policy_scoped # TODO: index should call policy_scope; remove this skip once it does
before_action :set_banner, only: %i[edit update destroy dismiss]
diff --git a/app/controllers/bulk_court_dates_controller.rb b/app/controllers/bulk_court_dates_controller.rb
index cd9848c6df..7541b90fba 100644
--- a/app/controllers/bulk_court_dates_controller.rb
+++ b/app/controllers/bulk_court_dates_controller.rb
@@ -1,6 +1,8 @@
class BulkCourtDatesController < ApplicationController
include CourtDateParams
+ layout "casa_app"
+ before_action -> { @active_nav = "cases" }
before_action :require_organization!
def new
diff --git a/app/controllers/casa_admins_controller.rb b/app/controllers/casa_admins_controller.rb
index 5cfa6cc309..8467b207e0 100644
--- a/app/controllers/casa_admins_controller.rb
+++ b/app/controllers/casa_admins_controller.rb
@@ -1,6 +1,8 @@
class CasaAdminsController < ApplicationController
include SmsBodyHelper
+ layout "casa_app"
+
before_action :set_admin, except: [:index, :new, :create]
before_action :require_organization!
after_action :verify_authorized
diff --git a/app/controllers/casa_cases_controller.rb b/app/controllers/casa_cases_controller.rb
index a486a2be33..de9fcb4acd 100644
--- a/app/controllers/casa_cases_controller.rb
+++ b/app/controllers/casa_cases_controller.rb
@@ -1,22 +1,30 @@
class CasaCasesController < ApplicationController
before_action :set_casa_case, only: %i[show edit update deactivate reactivate copy_court_orders]
before_action :set_contact_types, only: %i[new edit update create deactivate reactivate]
+ before_action -> { @active_nav = "cases" }, only: %i[edit update deactivate reactivate]
before_action :require_organization!
after_action :verify_authorized
+ SORT_COLUMNS = %w[case_number next_court_date status transition assigned].freeze
+
def index
authorize CasaCase
- org_cases = current_user.casa_org.casa_cases.includes(:assigned_volunteers, :casa_case_emancipation_categories)
- @casa_cases = policy_scope(org_cases).includes([:hearing_type, :judge])
- @casa_cases_filter_id = policy(CasaCase).can_see_filters? ? "casa-cases" : ""
- @duties = OtherDuty.where(creator_id: current_user.id)
+ @active_nav = "cases"
+ @sort = SORT_COLUMNS.include?(params[:sort]) ? params[:sort] : "case_number"
+ @direction = (params[:direction] == "desc") ? "desc" : "asc"
+ org_cases = current_user.casa_org.casa_cases.includes(:assigned_volunteers, :court_dates)
+ scope = policy_scope(org_cases)
+ scope = filter_casa_cases(scope) if policy(CasaCase).can_see_filters?
+ @pagy, @casa_cases = pagy(order_casa_cases(scope))
+ render :index, layout: "casa_app"
end
def show
authorize @casa_case
+ @active_nav = "cases"
respond_to do |format|
- format.html {}
+ format.html { render layout: "casa_app" }
format.csv do
case_contacts = @casa_case.decorate.case_contacts_ordered_by_occurred_at
csv = CaseContactsExportCsvService.new(case_contacts, CaseContactReport::COLUMNS).perform
@@ -32,11 +40,14 @@ def show
def new
@casa_case = CasaCase.new(casa_org: current_organization)
authorize @casa_case
+ @active_nav = "cases"
+ render layout: "casa_app"
end
def edit
@siblings_casa_cases = CasaCasePolicy::Scope.new(current_user, @casa_case).sibling_cases
authorize @casa_case
+ render layout: "casa_app"
end
def create
@@ -58,8 +69,9 @@ def create
else
set_contact_types
@empty_court_date = court_date_unknown?
+ @active_nav = "cases"
respond_to do |format|
- format.html { render :new, status: :unprocessable_content }
+ format.html { render :new, status: :unprocessable_content, layout: "casa_app" }
format.json { render json: @casa_case.errors.full_messages, status: :unprocessable_content }
end
end
@@ -79,7 +91,7 @@ def update
end
else
respond_to do |format|
- format.html { render :edit, status: :unprocessable_content }
+ format.html { render :edit, status: :unprocessable_content, layout: "casa_app" }
format.json { render json: @casa_case.errors.full_messages, status: :unprocessable_content }
end
end
@@ -101,7 +113,7 @@ def deactivate
end
else
respond_to do |format|
- format.html { render :edit, status: :unprocessable_content }
+ format.html { render :edit, status: :unprocessable_content, layout: "casa_app" }
format.json { render json: @casa_case.errors.full_messages, status: :unprocessable_content }
end
end
@@ -123,7 +135,7 @@ def reactivate
end
else
respond_to do |format|
- format.html { render :edit, status: :unprocessable_content }
+ format.html { render :edit, status: :unprocessable_content, layout: "casa_app" }
format.json { render json: @casa_case.errors.full_messages, status: :unprocessable_content }
end
end
@@ -136,10 +148,69 @@ def copy_court_orders
dup_court_order.save
@casa_case.case_court_orders.append dup_court_order
end
+ flash[:notice] = "Court orders have been copied."
end
private
+ # Orders the cases index by the whitelisted ?sort= column and ?direction=. Derived
+ # columns (next court date, assigned volunteer) use correlated subqueries; a secondary
+ # sort by case number keeps pagination stable.
+ def order_casa_cases(scope)
+ today = ActiveRecord::Base.connection.quote(Date.current)
+ clause =
+ case @sort
+ when "status" then "casa_cases.active"
+ when "transition" then "casa_cases.birth_month_year_youth"
+ when "next_court_date"
+ "(SELECT MIN(court_dates.date) FROM court_dates WHERE court_dates.casa_case_id = casa_cases.id AND court_dates.date >= #{today})"
+ when "assigned"
+ "(SELECT MIN(users.display_name) FROM case_assignments JOIN users ON users.id = case_assignments.volunteer_id WHERE case_assignments.casa_case_id = casa_cases.id AND case_assignments.active)"
+ else "casa_cases.case_number"
+ end
+ scope = scope.order(Arel.sql("#{clause} #{@direction} NULLS LAST"))
+ scope = scope.order(case_number: :asc) unless @sort == "case_number"
+ scope
+ end
+
+ # Server-side filtering for the cases index (admins/supervisors). Params come from the
+ # filter bar selects; volunteers never reach this. Status defaults to active.
+ def filter_casa_cases(scope)
+ if params[:search].present?
+ term = "%#{ActiveRecord::Base.sanitize_sql_like(params[:search].strip)}%"
+ scope = scope.where(
+ "casa_cases.case_number ILIKE :term OR EXISTS (SELECT 1 FROM case_assignments ca " \
+ "JOIN users u ON u.id = ca.volunteer_id WHERE ca.casa_case_id = casa_cases.id AND ca.active " \
+ "AND u.display_name ILIKE :term)",
+ term: term
+ )
+ end
+
+ scope = case params[:status]
+ when "inactive" then scope.inactive
+ when "all" then scope
+ else scope.active
+ end
+
+ case params[:assigned]
+ when "assigned" then scope = scope.where(id: CaseAssignment.active.select(:casa_case_id))
+ when "unassigned" then scope = scope.where.not(id: CaseAssignment.active.select(:casa_case_id))
+ end
+
+ case params[:transition]
+ when "yes" then scope = scope.is_transitioned
+ when "no" then scope = scope.where.not(id: current_user.casa_org.casa_cases.is_transitioned.select(:id))
+ end
+
+ case params[:prefix]
+ when "CINA" then scope = scope.where("case_number ILIKE ?", "CINA%")
+ when "TPR" then scope = scope.where("case_number ILIKE ?", "TPR%")
+ when "None" then scope = scope.where.not("case_number ILIKE ? OR case_number ILIKE ?", "CINA%", "TPR%")
+ end
+
+ scope
+ end
+
# Use callbacks to share common setup or constraints between actions.
def set_casa_case
@casa_case = current_organization.casa_cases.friendly.find(params[:id])
diff --git a/app/controllers/casa_org_controller.rb b/app/controllers/casa_org_controller.rb
index 010dc1dfa7..523268a469 100644
--- a/app/controllers/casa_org_controller.rb
+++ b/app/controllers/casa_org_controller.rb
@@ -12,9 +12,11 @@ class CasaOrgController < ApplicationController
before_action :require_organization!
after_action :verify_authorized
before_action :set_active_storage_url_options, only: %i[edit update]
+ before_action -> { @active_nav = "settings" }, only: %i[edit update]
def edit
authorize @casa_org
+ render layout: "casa_app"
end
def update
@@ -30,7 +32,7 @@ def update
end
else
respond_to do |format|
- format.html { render :edit, status: :unprocessable_content }
+ format.html { render :edit, status: :unprocessable_content, layout: "casa_app" }
format.json { render json: @casa_org.errors.full_messages, status: :unprocessable_content }
end
end
diff --git a/app/controllers/case_contacts/case_contacts_new_design_controller.rb b/app/controllers/case_contacts/case_contacts_new_design_controller.rb
index 1900cf7334..269ad27063 100644
--- a/app/controllers/case_contacts/case_contacts_new_design_controller.rb
+++ b/app/controllers/case_contacts/case_contacts_new_design_controller.rb
@@ -3,20 +3,42 @@ class CaseContacts::CaseContactsNewDesignController < ApplicationController
before_action :check_feature_flag
- def index
- load_case_contacts
- end
+ # Sortable columns on the casa_app table (server-side ?sort=/?direction=).
+ SORT_COLUMNS = %w[occurred_at medium_type contact_made].freeze
- def datatable
+ def index
authorize CaseContact
- case_contacts = policy_scope(current_organization.case_contacts)
- datatable = CaseContactDatatable.new(case_contacts, params, current_user)
+ @active_nav = "contacts"
+ @current_organization_groups = current_organization_groups
+ @filterable_cases = current_organization.casa_cases.order(:case_number)
+ @sort = SORT_COLUMNS.include?(params[:sort]) ? params[:sort] : "occurred_at"
+ @direction = (params[:direction] == "asc") ? "asc" : "desc"
+
+ scope = filter_case_contacts(policy_scope(current_organization.case_contacts))
+ .includes(:casa_case, :contact_types, :contact_topics, :followups, :creator, contact_topic_answers: :contact_topic)
+ order = Arel.sql("case_contacts.#{@sort} #{@direction} NULLS LAST, case_contacts.id DESC")
+ @pagy, @case_contacts = pagy(scope.order(order))
- render json: datatable
+ render layout: "casa_app"
end
private
+ # Maps the plain GET filter params to the CaseContact scopes. Contact type is a subquery so
+ # multi-type contacts are never duplicated by the join.
+ def filter_case_contacts(scope)
+ scope = scope.occurred_starting_at(params[:occurred_starting_at])
+ scope = scope.occurred_ending_at(params[:occurred_ending_at])
+ scope = scope.with_casa_case(params[:casa_case_ids]) if params[:casa_case_ids].present?
+ if params[:contact_type_ids].present?
+ scope = scope.where(id: CaseContact.joins(:contact_types).where(contact_types: {id: params[:contact_type_ids]}))
+ end
+ scope = scope.contact_medium(params[:contact_medium])
+ scope = scope.contact_made(params[:contact_made])
+ scope = scope.no_drafts(1) if params[:no_drafts].present?
+ scope
+ end
+
def check_feature_flag
unless Flipper.enabled?(:new_case_contact_table)
redirect_to case_contacts_path, alert: "This feature is not available."
diff --git a/app/controllers/case_contacts/followups_controller.rb b/app/controllers/case_contacts/followups_controller.rb
index 9da822b590..64b03c8ede 100644
--- a/app/controllers/case_contacts/followups_controller.rb
+++ b/app/controllers/case_contacts/followups_controller.rb
@@ -8,7 +8,7 @@ def create
FollowupService.create_followup(case_contact, current_user, note)
respond_to do |format|
- format.html { redirect_to casa_case_path(case_contact.casa_case) }
+ format.html { redirect_back_or_to casa_case_path(case_contact.casa_case) }
format.json { head :no_content }
end
end
@@ -21,7 +21,7 @@ def resolve
create_notification
respond_to do |format|
- format.html { redirect_to casa_case_path(@followup.case_contact.casa_case) }
+ format.html { redirect_back_or_to casa_case_path(@followup.case_contact.casa_case) }
format.json { head :no_content }
end
end
diff --git a/app/controllers/case_contacts/form_controller.rb b/app/controllers/case_contacts/form_controller.rb
index 3455cf705c..f8e1746703 100644
--- a/app/controllers/case_contacts/form_controller.rb
+++ b/app/controllers/case_contacts/form_controller.rb
@@ -1,8 +1,13 @@
class CaseContacts::FormController < ApplicationController
include Wicked::Wizard
+ # The wizard renders on the casadesign (Tailwind) shell. layout applies to the HTML
+ # render_wizard/render step paths; the autosave JSON responses skip it.
+ layout "casa_app"
+
before_action :require_organization!
before_action :set_case_contact, only: [:show, :update]
+ before_action :set_active_nav, only: [:show, :update]
after_action :verify_authorized
steps :details
@@ -42,6 +47,10 @@ def update
private
+ def set_active_nav
+ @active_nav = "contacts"
+ end
+
def set_case_contact
@case_contact = CaseContact
.includes(:creator, :contact_topic_answers)
@@ -53,12 +62,9 @@ def prepare_form
contact_types = get_contact_types.decorate
@grouped_contact_types = group_contact_types_by_name(contact_types)
@contact_topics = get_contact_topics
-
- if !@case_contact.active? && @case_contact.contact_topic_answers.empty?
- if @contact_topics.present?
- @case_contact.contact_topic_answers.create
- end
- end
+ # No pre-built blank answer: the Notes checklist lists every topic and creates an answer
+ # only when a topic is checked (contact-topics controller). A seeded blank row would just
+ # orphan a nil-topic answer.
end
def get_casa_cases
@@ -109,6 +115,9 @@ def finish_editing
update_volunteer_address(@case_contact)
flash[:notice] = message
if @case_contact.metadata["create_another"]
+ # "Submit & add another" reopens a fresh form, taking the user off the list -- so surface a
+ # link back to the case-contacts list (there's no per-contact show page) where it now appears
+ flash[:notice_action] = {"label" => "View case contacts", "path" => case_contacts_path}
redirect_to new_case_contact_path(params: {draft_case_ids:, ignore_referer: true})
else
redirect_back_to_referer(fallback_location: case_contacts_path(success: true))
@@ -125,7 +134,12 @@ def update_volunteer_address(case_contact)
return unless case_contact.volunteer_address.present? && !case_contact.address_field_disabled?
address = case_contact.volunteer.address || case_contact.volunteer.build_address
- address.update(content: case_contact.volunteer_address)
+ parts = case_contact.submitted_address_parts
+ if parts.values.any?(&:present?)
+ address.update(parts)
+ else
+ address.update(content: case_contact.volunteer_address)
+ end
end
# Makes a copy of the draft for all selected cases not including the first one. The draft becomes the contact for
diff --git a/app/controllers/case_contacts_controller.rb b/app/controllers/case_contacts_controller.rb
index c32204fc1e..f0c3040167 100644
--- a/app/controllers/case_contacts_controller.rb
+++ b/app/controllers/case_contacts_controller.rb
@@ -9,13 +9,17 @@ class CaseContactsController < ApplicationController
after_action :verify_authorized, except: %i[leave]
def index
+ @active_nav = "contacts"
load_case_contacts
+ render :index, layout: "casa_app" unless performed?
end
def drafts
authorize CaseContact
+ @active_nav = "contacts"
@case_contacts = current_organization.case_contacts.not_active
+ render layout: "casa_app"
end
def new
diff --git a/app/controllers/case_court_reports_controller.rb b/app/controllers/case_court_reports_controller.rb
index 247051d94d..2fec5716aa 100644
--- a/app/controllers/case_court_reports_controller.rb
+++ b/app/controllers/case_court_reports_controller.rb
@@ -7,7 +7,9 @@ class CaseCourtReportsController < ApplicationController
def index
authorize CaseCourtReport
+ @active_nav = "court_reports"
assigned_cases.select(:id, :case_number, :birth_month_year_youth)
+ render layout: "casa_app"
end
def show
diff --git a/app/controllers/case_groups_controller.rb b/app/controllers/case_groups_controller.rb
index 2a9c06e684..1db2a384e3 100644
--- a/app/controllers/case_groups_controller.rb
+++ b/app/controllers/case_groups_controller.rb
@@ -1,5 +1,7 @@
class CaseGroupsController < ApplicationController
+ layout "casa_app"
before_action :require_organization!
+ before_action -> { @active_nav = "cases" }
before_action :set_case_group, only: %i[edit update destroy]
def index
diff --git a/app/controllers/checklist_items_controller.rb b/app/controllers/checklist_items_controller.rb
index ac93da5128..cbf396c367 100644
--- a/app/controllers/checklist_items_controller.rb
+++ b/app/controllers/checklist_items_controller.rb
@@ -1,4 +1,6 @@
class ChecklistItemsController < ApplicationController
+ layout "casa_app"
+ before_action -> { @active_nav = "settings" }
before_action :authorize_checklist_item
before_action :set_hearing_type
before_action :set_checklist_item, except: [:new, :create]
diff --git a/app/controllers/contact_topics_controller.rb b/app/controllers/contact_topics_controller.rb
index 72f9df6261..9545e76d7d 100644
--- a/app/controllers/contact_topics_controller.rb
+++ b/app/controllers/contact_topics_controller.rb
@@ -1,4 +1,6 @@
class ContactTopicsController < ApplicationController
+ layout "casa_app"
+ before_action -> { @active_nav = "settings" }
before_action :set_contact_topic, only: %i[edit update soft_delete]
after_action :verify_authorized
diff --git a/app/controllers/contact_type_groups_controller.rb b/app/controllers/contact_type_groups_controller.rb
index 91f171e07e..19ac9747bf 100644
--- a/app/controllers/contact_type_groups_controller.rb
+++ b/app/controllers/contact_type_groups_controller.rb
@@ -1,4 +1,6 @@
class ContactTypeGroupsController < ApplicationController
+ layout "casa_app"
+ before_action -> { @active_nav = "settings" }
before_action :set_contact_type_group, except: [:new, :create]
after_action :verify_authorized
diff --git a/app/controllers/contact_types_controller.rb b/app/controllers/contact_types_controller.rb
index 5a5cca5985..b93861d8bc 100644
--- a/app/controllers/contact_types_controller.rb
+++ b/app/controllers/contact_types_controller.rb
@@ -1,4 +1,6 @@
class ContactTypesController < ApplicationController
+ layout "casa_app"
+ before_action -> { @active_nav = "settings" }
before_action :set_contact_type, except: [:new, :create]
after_action :verify_authorized
diff --git a/app/controllers/court_dates_controller.rb b/app/controllers/court_dates_controller.rb
index 518ae709dd..871952f9d3 100644
--- a/app/controllers/court_dates_controller.rb
+++ b/app/controllers/court_dates_controller.rb
@@ -1,6 +1,8 @@
class CourtDatesController < ApplicationController
include CourtDateParams
+ layout "casa_app"
+ before_action -> { @active_nav = "cases" }
before_action :set_casa_case
before_action :set_court_date, only: %i[edit show update destroy]
before_action :require_organization!
diff --git a/app/controllers/custom_org_links_controller.rb b/app/controllers/custom_org_links_controller.rb
index d62794d98f..0b05ad3979 100644
--- a/app/controllers/custom_org_links_controller.rb
+++ b/app/controllers/custom_org_links_controller.rb
@@ -1,4 +1,6 @@
class CustomOrgLinksController < ApplicationController
+ layout "casa_app"
+ before_action -> { @active_nav = "settings" }
before_action :set_custom_org_link, only: %i[edit update destroy]
after_action :verify_authorized
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 087f370a9d..f332fc81fd 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -7,11 +7,17 @@ def show
if volunteer_with_only_one_active_case?
redirect_to new_case_contact_path
elsif current_user.volunteer?
- redirect_to casa_cases_path
+ @active_nav = "dashboard"
+ @dashboard = VolunteerDashboard.new(current_user)
+ render "dashboard/volunteer", layout: "casa_app"
elsif current_user.supervisor?
- redirect_to volunteers_path
+ @active_nav = "dashboard"
+ @dashboard = SupervisorDashboard.new(current_user)
+ render "dashboard/supervisor", layout: "casa_app"
elsif current_user.casa_admin?
- redirect_to supervisors_path
+ @active_nav = "dashboard"
+ @dashboard = AdminDashboard.new(current_organization)
+ render "dashboard/admin", layout: "casa_app"
end
end
diff --git a/app/controllers/emancipation_checklists_controller.rb b/app/controllers/emancipation_checklists_controller.rb
index a004ff1435..ba15c9b01f 100644
--- a/app/controllers/emancipation_checklists_controller.rb
+++ b/app/controllers/emancipation_checklists_controller.rb
@@ -1,5 +1,7 @@
class EmancipationChecklistsController < ApplicationController
include DateHelper
+ layout "casa_app"
+ before_action -> { @active_nav = "cases" }
before_action :require_organization!
after_action :verify_authorized
diff --git a/app/controllers/emancipations_controller.rb b/app/controllers/emancipations_controller.rb
index 90144e3e52..8c8542065c 100644
--- a/app/controllers/emancipations_controller.rb
+++ b/app/controllers/emancipations_controller.rb
@@ -1,4 +1,6 @@
class EmancipationsController < ApplicationController
+ layout "casa_app"
+ before_action -> { @active_nav = "cases" }
before_action :require_organization!
after_action :verify_authorized
ADD_CATEGORY = "add_category"
diff --git a/app/controllers/error_controller.rb b/app/controllers/error_controller.rb
index 7aa5294dfa..46b9a4b020 100644
--- a/app/controllers/error_controller.rb
+++ b/app/controllers/error_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class ErrorController < ApplicationController
+ layout "error"
+
skip_before_action :authenticate_user!
skip_after_action :verify_authorized
skip_after_action :verify_policy_scoped # TODO: index should call policy_scope; remove this skip once it does
diff --git a/app/controllers/fund_requests_controller.rb b/app/controllers/fund_requests_controller.rb
index 2d213449d1..4050866fb1 100644
--- a/app/controllers/fund_requests_controller.rb
+++ b/app/controllers/fund_requests_controller.rb
@@ -1,5 +1,8 @@
class FundRequestsController < ApplicationController
+ layout "casa_app"
+
before_action :verify_casa_case
+ before_action -> { @active_nav = "cases" }
after_action :verify_authorized
def new
@@ -13,7 +16,7 @@ def create
if @fund_request.save
FundRequestMailer.send_request(nil, @fund_request).deliver
- redirect_to casa_case_path(@casa_case), notice: "Fund Request was sent for case #{@casa_case.case_number}"
+ redirect_to casa_case_path(@casa_case), notice: "Fund request was sent for case #{@casa_case.case_number}"
else
render :new, status: :unprocessable_content
end
diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb
index bbc16f505c..53f2b5be79 100644
--- a/app/controllers/health_controller.rb
+++ b/app/controllers/health_controller.rb
@@ -3,15 +3,16 @@
class HealthController < ApplicationController
skip_before_action :authenticate_user!
skip_after_action :verify_authorized
- skip_after_action :verify_policy_scoped # TODO: index should call policy_scope; remove this skip once it does
+ skip_after_action :verify_policy_scoped
before_action :verify_token_for_old_object_stats, only: [:old_objects]
+ # Public ops health check. HTML renders a minimal, self-contained status page; JSON
+ # returns the latest deploy time (consumed by uptime monitors). Activity charts have
+ # moved to the authenticated all-CASA "Metrics" console and the per-chapter "Analytics"
+ # page (see MetricsReport), so this public endpoint exposes no cross-org data.
def index
respond_to do |format|
- format.html do
- render :index
- end
-
+ format.html { render :index, layout: false }
format.json { render json: {latest_deploy_time: Health.instance.latest_deploy_time} }
end
end
@@ -27,62 +28,6 @@ def old_objects
content_type: "application/json"
end
- def case_contacts_creation_times_in_last_week
- case_contacts_created_in_last_week = CaseContact.where("created_at >= ?", 1.week.ago)
-
- unix_timestamps_of_case_contacts_created_in_last_week = case_contacts_created_in_last_week.pluck(:created_at).map { |creation_time| creation_time.to_i }
-
- render json: {timestamps: unix_timestamps_of_case_contacts_created_in_last_week}
- end
-
- def monthly_line_graph_data
- first_day_of_last_12_months = (12.months.ago.to_date..Date.current).select { |date| date.day == 1 }.map { |date| date.beginning_of_month }
-
- if first_day_of_last_12_months.size > 12
- first_day_of_last_12_months = first_day_of_last_12_months[1..12]
- end
-
- monthly_counts_of_case_contacts_created = CaseContact.group_by_month(:created_at, last: 12).count
- monthly_counts_of_case_contacts_with_notes_created = CaseContact.left_outer_joins(:contact_topic_answers).where("case_contacts.notes != '' OR contact_topic_answers.value != ''").select(:id).distinct.group_by_month(:created_at, last: 12).count
- monthly_counts_of_users_who_have_created_case_contacts = CaseContact.select(:creator_id).distinct.group_by_month(:created_at, last: 12).count
-
- monthly_line_graph_combined_data = first_day_of_last_12_months.map do |month|
- [
- month.strftime("%b %Y"),
- monthly_counts_of_case_contacts_created[month],
- monthly_counts_of_case_contacts_with_notes_created[month],
- monthly_counts_of_users_who_have_created_case_contacts[month]
- ]
- end
-
- render json: monthly_line_graph_combined_data
- end
-
- def monthly_unique_users_graph_data
- first_day_of_last_12_months = (12.months.ago.to_date..Date.current).select { |date| date.day == 1 }.map { |date| date.beginning_of_month.strftime("%b %Y") }
-
- if first_day_of_last_12_months.size > 12
- first_day_of_last_12_months = first_day_of_last_12_months[1..12]
- end
-
- monthly_counts_of_volunteers = LoginActivity.joins("INNER JOIN users ON users.id = login_activities.user_id AND login_activities.user_type = 'User'").where(users: {type: "Volunteer"}, success: true).group_by_month(:created_at, format: "%b %Y").distinct.count(:user_id)
- monthly_counts_of_supervisors = LoginActivity.joins("INNER JOIN users ON users.id = login_activities.user_id AND login_activities.user_type = 'User'").where(users: {type: "Supervisor"}, success: true).group_by_month(:created_at, format: "%b %Y").distinct.count(:user_id)
- monthly_counts_of_casa_admins = LoginActivity.joins("INNER JOIN users ON users.id = login_activities.user_id AND login_activities.user_type = 'User'").where(users: {type: "CasaAdmin"}, success: true).group_by_month(:created_at, format: "%b %Y").distinct.count(:user_id)
- monthly_logged_counts_of_volunteers = CaseContact.joins(supervisor_volunteer: :volunteer).group_by_month(:created_at, format: "%b %Y").distinct.count(:creator_id)
-
- monthly_line_graph_combined_data = first_day_of_last_12_months.map do |month|
- [
- month,
- monthly_counts_of_volunteers[month] || 0,
- monthly_counts_of_supervisors[month] || 0,
- monthly_counts_of_casa_admins[month] || 0,
- monthly_logged_counts_of_volunteers[month] || 0
- ]
- end
-
- render json: monthly_line_graph_combined_data
- end
-
private
def each_old_object
diff --git a/app/controllers/hearing_types_controller.rb b/app/controllers/hearing_types_controller.rb
index 524ddf5ec5..607eab3f83 100644
--- a/app/controllers/hearing_types_controller.rb
+++ b/app/controllers/hearing_types_controller.rb
@@ -1,4 +1,6 @@
class HearingTypesController < ApplicationController
+ layout "casa_app"
+ before_action -> { @active_nav = "settings" }
before_action :set_hearing_type, except: [:new, :create]
after_action :verify_authorized
diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb
index 628d15d34a..58f8032211 100644
--- a/app/controllers/imports_controller.rb
+++ b/app/controllers/imports_controller.rb
@@ -2,6 +2,8 @@ class ImportsController < ApplicationController
require "csv"
include ActionView::Helpers::UrlHelper
+ layout "casa_app"
+ before_action -> { @active_nav = "settings" }
before_action :failed_csv_service, only: [:create, :download_failed]
after_action :verify_authorized
skip_after_action :verify_policy_scoped # TODO: index should call policy_scope; remove this skip once it does
diff --git a/app/controllers/judges_controller.rb b/app/controllers/judges_controller.rb
index 68dd5574df..d6074db9e8 100644
--- a/app/controllers/judges_controller.rb
+++ b/app/controllers/judges_controller.rb
@@ -1,4 +1,6 @@
class JudgesController < ApplicationController
+ layout "casa_app"
+ before_action -> { @active_nav = "settings" }
before_action :set_judge, except: [:new, :create]
after_action :verify_authorized
diff --git a/app/controllers/languages_controller.rb b/app/controllers/languages_controller.rb
index bc6883fd22..e5efd5fc54 100644
--- a/app/controllers/languages_controller.rb
+++ b/app/controllers/languages_controller.rb
@@ -1,4 +1,6 @@
class LanguagesController < ApplicationController
+ layout "casa_app"
+ before_action -> { @active_nav = "settings" }
before_action :set_language, only: %i[edit update]
def new
diff --git a/app/controllers/learning_hour_topics_controller.rb b/app/controllers/learning_hour_topics_controller.rb
index c42d4b2a1f..d8e76c5d1c 100644
--- a/app/controllers/learning_hour_topics_controller.rb
+++ b/app/controllers/learning_hour_topics_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class LearningHourTopicsController < ApplicationController
+ layout "casa_app"
+ before_action -> { @active_nav = "settings" }
before_action :set_learning_hour_topic, only: %i[edit update]
after_action :verify_authorized
diff --git a/app/controllers/learning_hour_types_controller.rb b/app/controllers/learning_hour_types_controller.rb
index 8314baef0d..593f4ef67b 100644
--- a/app/controllers/learning_hour_types_controller.rb
+++ b/app/controllers/learning_hour_types_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class LearningHourTypesController < ApplicationController
+ layout "casa_app"
+ before_action -> { @active_nav = "settings" }
before_action :set_learning_hour_type, only: %i[edit update]
after_action :verify_authorized
diff --git a/app/controllers/learning_hours/volunteers_controller.rb b/app/controllers/learning_hours/volunteers_controller.rb
index 1b228bc3f5..d1094d66b2 100644
--- a/app/controllers/learning_hours/volunteers_controller.rb
+++ b/app/controllers/learning_hours/volunteers_controller.rb
@@ -4,7 +4,9 @@ class LearningHours::VolunteersController < ApplicationController
def show
authorize @volunteer
+ @active_nav = "learning"
@learning_hours = LearningHour.where(user: @volunteer)
+ render layout: "casa_app"
end
private
diff --git a/app/controllers/learning_hours_controller.rb b/app/controllers/learning_hours_controller.rb
index d36579420f..bbc0541467 100644
--- a/app/controllers/learning_hours_controller.rb
+++ b/app/controllers/learning_hours_controller.rb
@@ -1,21 +1,38 @@
class LearningHoursController < ApplicationController
before_action :set_learning_hour, only: %i[show edit update destroy]
+ before_action :set_active_nav, only: %i[index show new create edit update]
after_action :verify_authorized, except: :index # TODO add this back and fix all tests
def index
authorize LearningHour
- @learning_hours = LearningHoursDashboardRowsService
+ rows = LearningHoursDashboardRowsService
.new(current_user, policy_scope(LearningHour))
.perform
+
+ if current_user.volunteer?
+ @learning_hours = rows
+ else
+ # Supervisor/admin roster: rows are one per volunteer (an array for supervisors, a
+ # relation for admins). Paginate uniformly as an array with Pagy.
+ rows = rows.to_a
+ per_page = 25
+ page = params[:page].to_i.clamp(1, [(rows.size.to_f / per_page).ceil, 1].max)
+ @pagy = Pagy.new(count: rows.size, page: page, limit: per_page)
+ @learning_hours = rows[@pagy.offset, per_page] || []
+ end
+
+ render :index, layout: "casa_app"
end
def show
authorize @learning_hour
+ render layout: "casa_app"
end
def new
authorize LearningHour
@learning_hour = LearningHour.new
+ render layout: "casa_app"
end
def create
@@ -26,13 +43,14 @@ def create
if @learning_hour.save
format.html { redirect_to learning_hours_path, notice: "New entry was successfully created." }
else
- format.html { render :new, status: :unprocessable_content }
+ format.html { render :new, status: :unprocessable_content, layout: "casa_app" }
end
end
end
def edit
authorize @learning_hour
+ render layout: "casa_app"
end
def update
@@ -41,7 +59,7 @@ def update
if @learning_hour.update(update_learning_hours_params)
format.html { redirect_to learning_hour_path(@learning_hour), notice: "Entry was successfully updated." }
else
- format.html { render :edit, status: :unprocessable_content }
+ format.html { render :edit, status: :unprocessable_content, layout: "casa_app" }
end
end
end
@@ -61,6 +79,10 @@ def set_learning_hour
redirect_to learning_hours_path
end
+ def set_active_nav
+ @active_nav = "learning"
+ end
+
def learning_hours_params
params.require(:learning_hour).permit(:occurred_at, :duration_minutes, :duration_hours, :name, :user_id,
:learning_hour_type_id, :learning_hour_topic_id)
diff --git a/app/controllers/mileage_rates_controller.rb b/app/controllers/mileage_rates_controller.rb
index c1c27ca230..f584b16ad2 100644
--- a/app/controllers/mileage_rates_controller.rb
+++ b/app/controllers/mileage_rates_controller.rb
@@ -1,4 +1,6 @@
class MileageRatesController < ApplicationController
+ layout "casa_app"
+ before_action -> { @active_nav = "settings" }
after_action :verify_authorized
skip_after_action :verify_policy_scoped # TODO: index should call policy_scope; remove this skip once it does
before_action :set_mileage_rate, only: %i[edit update]
diff --git a/app/controllers/notes_controller.rb b/app/controllers/notes_controller.rb
index e7968c7850..b7ba75d322 100644
--- a/app/controllers/notes_controller.rb
+++ b/app/controllers/notes_controller.rb
@@ -1,4 +1,6 @@
class NotesController < ApplicationController
+ layout "casa_app"
+
before_action :find_volunteer
before_action :find_note, only: %i[edit update destroy]
@@ -10,6 +12,7 @@ def create
def edit
authorize @note
+ @active_nav = "volunteers"
end
def update
diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb
index 8726d2d21f..d2d55bbfcf 100644
--- a/app/controllers/notifications_controller.rb
+++ b/app/controllers/notifications_controller.rb
@@ -1,4 +1,6 @@
class NotificationsController < ApplicationController
+ layout "casa_app"
+
after_action :verify_authorized
skip_after_action :verify_policy_scoped # TODO: index should call policy_scope; remove this skip once it does
before_action :set_notification, only: %i[mark_as_read]
diff --git a/app/controllers/other_duties_controller.rb b/app/controllers/other_duties_controller.rb
index 8e6e22e66c..5073195db1 100644
--- a/app/controllers/other_duties_controller.rb
+++ b/app/controllers/other_duties_controller.rb
@@ -1,4 +1,6 @@
class OtherDutiesController < ApplicationController
+ layout "casa_app"
+
before_action :set_other_duty, except: [:new, :create, :index]
before_action :convert_duration_minutes, only: [:update, :create]
skip_after_action :verify_policy_scoped # TODO: index should call policy_scope; remove this skip once it does
diff --git a/app/controllers/placement_types_controller.rb b/app/controllers/placement_types_controller.rb
index 23141d679b..06c48daaea 100644
--- a/app/controllers/placement_types_controller.rb
+++ b/app/controllers/placement_types_controller.rb
@@ -1,4 +1,6 @@
class PlacementTypesController < ApplicationController
+ layout "casa_app"
+ before_action -> { @active_nav = "settings" }
before_action :set_placement_type, only: %i[edit update]
after_action :verify_authorized
after_action :verify_policy_scoped
diff --git a/app/controllers/placements_controller.rb b/app/controllers/placements_controller.rb
index f8a8fdb45e..b7cc86f4ab 100644
--- a/app/controllers/placements_controller.rb
+++ b/app/controllers/placements_controller.rb
@@ -1,4 +1,6 @@
class PlacementsController < ApplicationController
+ layout "casa_app"
+ before_action -> { @active_nav = "cases" }
before_action :set_casa_case
before_action :set_placement, only: %i[edit show update destroy]
before_action :require_organization!
diff --git a/app/controllers/reimbursements_controller.rb b/app/controllers/reimbursements_controller.rb
index 7ec4f9b1a2..3f6832ccae 100644
--- a/app/controllers/reimbursements_controller.rb
+++ b/app/controllers/reimbursements_controller.rb
@@ -5,24 +5,15 @@ def new
def index
authorize :reimbursement
+ @active_nav = "reimbursements"
@complete_status = params[:status] == "complete"
- @datatable_url = datatable_reimbursements_path(format: :json, status: params[:status])
- @volunteers_for_filter = volunteers_for_filter(
- fetch_filtered_reimbursements(@complete_status)
- )
- @occurred_at_filter_start_date = (Time.now - 1.year).strftime("%Y/%m/%d")
- # @grouped_reimbursements = @reimbursements.group_by { |cc| "#{cc.occurred_at}-#{cc.creator_id}" }
- end
-
- def datatable
- authorize :reimbursement
-
- @complete_status = params[:status] == "complete"
- datatable = ReimbursementDatatable.new(
- fetch_filtered_reimbursements(@complete_status), params
- )
-
- render json: datatable
+ # Volunteer options come from the status-scoped set (before the volunteer/occurred-at
+ # filters) so selecting a volunteer does not collapse the dropdown to that one option.
+ scoped = status_scoped_reimbursements(@complete_status)
+ @volunteers_for_filter = volunteers_for_filter(scoped)
+ @occurred_at_filter_start_date = 1.year.ago.to_date
+ @pagy, @reimbursements = pagy(apply_filters_to_query(scoped).order(occurred_at: :desc))
+ render :index, layout: "casa_app"
end
def change_complete_status
@@ -47,7 +38,7 @@ def change_complete_status
private
def apply_filters_to_query(query)
- query = query.where(creator_id: params[:volunteers]) if params[:volunteers]
+ query = query.where(creator_id: params[:volunteers]) if params[:volunteers].present?
apply_occurred_at_filters(query)
end
@@ -81,17 +72,15 @@ def fetch_reimbursements
policy_scope(case_contacts, policy_scope_class: ReimbursementPolicy::Scope)
end
- def fetch_filtered_reimbursements(complete_only)
- apply_filters_to_query(
- fetch_reimbursements
- .want_driving_reimbursement(true)
- .created_max_ago(1.year.ago)
- .filter_by_reimbursement_status(complete_only)
- )
+ def status_scoped_reimbursements(complete_only)
+ fetch_reimbursements
+ .want_driving_reimbursement(true)
+ .created_max_ago(1.year.ago)
+ .filter_by_reimbursement_status(complete_only)
end
def get_normalised_time_for_occurred_at_filter(key)
- normalised_date = Date.strptime(params[:occurred_at][key], "%Y/%m/%d")
+ normalised_date = Date.parse(params[:occurred_at][key])
normalised_time = DateTime.new(normalised_date.year, normalised_date.month, normalised_date.day)
return normalised_time if key == :start
diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb
index df0685c6ab..8c03c39880 100644
--- a/app/controllers/reports_controller.rb
+++ b/app/controllers/reports_controller.rb
@@ -4,6 +4,8 @@ class ReportsController < ApplicationController
def index
authorize :application, :see_reports_page?
+ @active_nav = "reports"
+ render layout: "casa_app"
end
def export_emails
diff --git a/app/controllers/supervisors_controller.rb b/app/controllers/supervisors_controller.rb
index c6415ae021..c6a5531f9e 100644
--- a/app/controllers/supervisors_controller.rb
+++ b/app/controllers/supervisors_controller.rb
@@ -7,18 +7,25 @@ class SupervisorsController < ApplicationController
before_action :set_supervisor, only: [:edit, :update, :activate, :deactivate, :resend_invitation, :change_to_admin]
before_action :all_volunteers_ever_assigned, only: [:update]
before_action :supervisor_has_unassigned_volunteers, only: [:edit]
+ before_action :set_active_nav, only: [:index, :new, :create, :edit, :update, :activate, :deactivate]
after_action :verify_authorized
def index
authorize Supervisor
- @supervisors = policy_scope(current_organization.supervisors)
+ @status = %w[active inactive all].include?(params[:status]) ? params[:status] : "active"
+ supervisors = policy_scope(current_organization.supervisors)
+ supervisors = supervisors.where(active: true) if @status == "active"
+ supervisors = supervisors.where(active: false) if @status == "inactive"
+ @supervisors = supervisors.order(:display_name)
@casa_cases = current_organization.casa_cases.missing_court_dates
+ render :index, layout: "casa_app"
end
def new
authorize Supervisor
@supervisor = Supervisor.new
+ render layout: "casa_app"
end
def create
@@ -35,7 +42,7 @@ def create
sms_status = deliver_sms_to @supervisor, body_msg
redirect_to edit_supervisor_path(@supervisor), notice: sms_acct_creation_notice("supervisor", sms_status)
else
- render new_supervisor_path, status: :unprocessable_content
+ render :new, status: :unprocessable_content, layout: "casa_app"
end
end
@@ -45,6 +52,7 @@ def edit
all_volunteers_ever_assigned
end
@unassigned_volunteer_count ||= 0
+ render layout: "casa_app"
end
def update
@@ -55,7 +63,7 @@ def update
@supervisor.filter_old_emails!(@supervisor.email)
redirect_to edit_supervisor_path(@supervisor), notice: notice
else
- render :edit, status: :unprocessable_content
+ render :edit, status: :unprocessable_content, layout: "casa_app"
end
end
@@ -66,7 +74,7 @@ def activate
redirect_to edit_supervisor_path(@supervisor), notice: "Supervisor was activated. They have been sent an email."
else
- render :edit, notice: "Supervisor could not be activated."
+ render :edit, layout: "casa_app", notice: "Supervisor could not be activated."
end
end
@@ -75,7 +83,7 @@ def deactivate
if @supervisor.deactivate
redirect_to edit_supervisor_path(@supervisor), notice: "Supervisor was deactivated."
else
- render :edit, notice: "Supervisor could not be deactivated."
+ render :edit, layout: "casa_app", notice: "Supervisor could not be deactivated."
end
end
@@ -93,20 +101,16 @@ def change_to_admin
redirect_to edit_casa_admin_path(@supervisor), notice: "Supervisor was changed to Admin."
end
- def datatable
- authorize Supervisor
- supervisors = policy_scope(current_organization.supervisors)
- datatable = SupervisorDatatable.new supervisors, params
-
- render json: datatable
- end
-
private
def set_supervisor
@supervisor = Supervisor.find(params[:id])
end
+ def set_active_nav
+ @active_nav = "supervisors"
+ end
+
def all_volunteers_ever_assigned
@unassigned_volunteer_count = @supervisor.volunteers_ever_assigned.count - @supervisor.volunteers.count
@all_volunteers_ever_assigned = @supervisor.volunteers_ever_assigned
diff --git a/app/controllers/users/confirmations_controller.rb b/app/controllers/users/confirmations_controller.rb
new file mode 100644
index 0000000000..9f686b917a
--- /dev/null
+++ b/app/controllers/users/confirmations_controller.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+# Renders Devise's confirmation (resend) page on the casa_auth shell instead of the retired
+# Bootstrap layouts/devise.
+class Users::ConfirmationsController < Devise::ConfirmationsController
+ layout "casa_auth"
+end
diff --git a/app/controllers/users/invitations_controller.rb b/app/controllers/users/invitations_controller.rb
index 7dbdbc8352..44df716590 100644
--- a/app/controllers/users/invitations_controller.rb
+++ b/app/controllers/users/invitations_controller.rb
@@ -4,6 +4,6 @@ def edit
self.resource = resource_class.new
set_minimum_password_length if respond_to?(:set_minimum_password_length, true)
resource.invitation_token = params[:invitation_token]
- render :edit
+ render :edit, layout: "casa_auth"
end
end
diff --git a/app/controllers/users/passwords_controller.rb b/app/controllers/users/passwords_controller.rb
index f1c2ea6a90..faf852acbc 100644
--- a/app/controllers/users/passwords_controller.rb
+++ b/app/controllers/users/passwords_controller.rb
@@ -3,6 +3,8 @@ class Users::PasswordsController < Devise::PasswordsController
include PhoneNumberHelper
include SmsBodyHelper
+ layout "casa_auth"
+
def create
@email = params.dig(resource_name, :email)
@phone_number = params.dig(resource_name, :phone_number)
diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb
index a1afeb575d..ae8b49d324 100644
--- a/app/controllers/users/sessions_controller.rb
+++ b/app/controllers/users/sessions_controller.rb
@@ -2,5 +2,6 @@
class Users::SessionsController < Devise::SessionsController
include Accessible
+ layout "casa_auth"
skip_before_action :check_user, only: :destroy
end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 7ad32b2109..bc2fa540c9 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1,4 +1,6 @@
class UsersController < ApplicationController
+ layout "casa_app"
+
before_action :get_user
before_action :authorize_user_with_policy
before_action :set_active_casa_admins
@@ -126,9 +128,9 @@ def update_user_email
def user_params
if !current_user.casa_admin?
- params.require(:user).permit(:display_name, :phone_number, :date_of_birth, :receive_sms_notifications, :receive_email_notifications, sms_notification_event_ids: [], address_attributes: [:id, :content])
+ params.require(:user).permit(:display_name, :phone_number, :date_of_birth, :receive_sms_notifications, :receive_email_notifications, sms_notification_event_ids: [], address_attributes: [:id, :content, :line_1, :line_2, :city, :state, :zip])
else
- params.require(:user).permit(:email, :display_name, :phone_number, :date_of_birth, :receive_sms_notifications, :receive_email_notifications, sms_notification_event_ids: [], address_attributes: [:id, :content])
+ params.require(:user).permit(:email, :display_name, :phone_number, :date_of_birth, :receive_sms_notifications, :receive_email_notifications, sms_notification_event_ids: [], address_attributes: [:id, :content, :line_1, :line_2, :city, :state, :zip])
end
end
diff --git a/app/controllers/volunteers_controller.rb b/app/controllers/volunteers_controller.rb
index 0573c63b64..bf5aa8d5b5 100644
--- a/app/controllers/volunteers_controller.rb
+++ b/app/controllers/volunteers_controller.rb
@@ -1,12 +1,29 @@
class VolunteersController < ApplicationController
include SmsBodyHelper
- before_action :set_volunteer, except: %i[index new create datatable stop_impersonating]
+ before_action :set_volunteer, except: %i[index new create stop_impersonating]
+ before_action :set_edit_context, only: %i[edit update activate deactivate]
after_action :verify_authorized, except: %i[stop_impersonating]
def index
authorize Volunteer
- @supervisors = policy_scope(current_organization.supervisors)
+ @active_nav = "volunteers"
+ @supervisors = policy_scope(current_organization.supervisors.active)
+ @search = params[:search].to_s
+ @status = %w[active inactive all].include?(params[:status]) ? params[:status] : "active"
+ @supervisor_filter = params[:supervisor].to_s
+ @transition = %w[yes no].include?(params[:transition]) ? params[:transition] : ""
+ @extra_languages = %w[yes no].include?(params[:languages]) ? params[:languages] : ""
+ @sort = VolunteerDatatable::ORDERABLE_FIELDS.include?(params[:sort]) ? params[:sort] : "display_name"
+ @direction = (params[:direction] == "desc") ? "desc" : "asc"
+
+ datatable = VolunteerDatatable.new(policy_scope(current_organization.volunteers), volunteer_index_params)
+ count = datatable.index_count
+ per_page = 25
+ page = params[:page].to_i.clamp(1, [(count.to_f / per_page).ceil, 1].max)
+ @pagy = Pagy.new(count: count, page: page, limit: per_page)
+ @volunteers = datatable.index_relation.offset(@pagy.offset).limit(per_page).to_a
+ render :index, layout: "casa_app"
end
def show
@@ -14,17 +31,11 @@ def show
redirect_to action: :edit
end
- def datatable
- authorize Volunteer
- volunteers = policy_scope current_organization.volunteers
- datatable = VolunteerDatatable.new volunteers, params
-
- render json: datatable
- end
-
def new
@volunteer = current_organization.volunteers.new
authorize @volunteer
+ @active_nav = "volunteers"
+ render layout: "casa_app"
end
def create
@@ -50,13 +61,14 @@ def create
sms_status = deliver_sms_to @volunteer, account_activation_msg("volunteer", hash_of_short_urls)
redirect_to edit_volunteer_path(@volunteer), notice: sms_acct_creation_notice("volunteer", sms_status)
else
- render :new, status: :unprocessable_content
+ @active_nav = "volunteers"
+ render :new, status: :unprocessable_content, layout: "casa_app"
end
end
def edit
authorize @volunteer
- @supervisors = policy_scope current_organization.supervisors.active
+ render layout: "casa_app"
end
def update
@@ -67,7 +79,7 @@ def update
@volunteer.filter_old_emails!(@volunteer.email)
redirect_to edit_volunteer_path(@volunteer), notice: notice
else
- render :edit, status: :unprocessable_content
+ render :edit, status: :unprocessable_content, layout: "casa_app"
end
end
@@ -82,7 +94,7 @@ def activate
redirect_to edit_volunteer_path(@volunteer), notice: "Volunteer was activated. They have been sent an email."
end
else
- render :edit, status: :unprocessable_content
+ render :edit, status: :unprocessable_content, layout: "casa_app"
end
end
@@ -91,7 +103,7 @@ def deactivate
if @volunteer.deactivate
redirect_to edit_volunteer_path(@volunteer), notice: "Volunteer was deactivated."
else
- render :edit, status: :unprocessable_content
+ render :edit, status: :unprocessable_content, layout: "casa_app"
end
end
@@ -131,7 +143,7 @@ def reminder
end
VolunteerMailer.case_contacts_reminder(@volunteer, cc_recipients).deliver
- redirect_to edit_volunteer_path(@volunteer), notice: "Reminder sent to volunteer."
+ redirect_back_or_to edit_volunteer_path(@volunteer), notice: "Reminder sent to volunteer."
end
def impersonate
@@ -151,6 +163,50 @@ def set_volunteer
@volunteer = Volunteer.find(params[:id])
end
+ # Shared setup for the actions that render the casa_app edit page (edit + the
+ # update/activate/deactivate failure re-renders): light up the sidebar nav and
+ # load the active supervisors the "assign a supervisor" form needs.
+ def set_edit_context
+ @active_nav = "volunteers"
+ @supervisors = policy_scope current_organization.supervisors.active
+ end
+
+ # Map the index's plain GET filters into the DataTables param shape VolunteerDatatable
+ # understands, so the migrated (bespoke Pagy) index reuses its exact filter/search/order SQL.
+ def volunteer_index_params
+ {
+ search: {value: @search},
+ additional_filters: {
+ active: volunteer_active_filter,
+ supervisor: volunteer_supervisor_filter,
+ transition_aged_youth: (@transition.present? ? [(@transition == "yes").to_s] : %w[true false]),
+ extra_languages: (@extra_languages.present? ? [(@extra_languages == "yes").to_s] : nil)
+ },
+ columns: {"0" => {name: @sort}},
+ order: {"0" => {column: "0", dir: @direction}}
+ }.with_indifferent_access
+ end
+
+ def volunteer_active_filter
+ case @status
+ when "inactive" then %w[false]
+ when "all" then %w[true false]
+ else %w[true]
+ end
+ end
+
+ # The datatable's supervisor filter is value-list based: [""] means "no supervisor", a list of
+ # ids means those supervisors, and "" mixed with ids means "null OR those". "All" therefore
+ # passes "" + every active supervisor id so it also includes volunteers whose supervisor is
+ # inactive/absent (their joined supervisor is null).
+ def volunteer_supervisor_filter
+ case @supervisor_filter
+ when "", "all" then ["", *@supervisors.map { |s| s.id.to_s }]
+ when "unassigned" then [""]
+ else [@supervisor_filter]
+ end
+ end
+
def generate_devise_password
Devise.friendly_token.first(8)
end
diff --git a/app/datatables/case_contact_datatable.rb b/app/datatables/case_contact_datatable.rb
deleted file mode 100644
index 7240caf1a9..0000000000
--- a/app/datatables/case_contact_datatable.rb
+++ /dev/null
@@ -1,114 +0,0 @@
-# frozen_string_literal: true
-
-class CaseContactDatatable < ApplicationDatatable
- ORDERABLE_FIELDS = %w[
- occurred_at
- contact_made
- medium_type
- duration_minutes
- ].freeze
-
- def initialize(base_relation, params, current_user)
- super(base_relation, params)
- @current_user = current_user
- end
-
- private
-
- attr_reader :current_user
-
- def data
- records.map do |case_contact|
- policy = CaseContactPolicy.new(current_user, case_contact)
- requested_followup = case_contact.followups.find(&:requested?)
-
- {
- id: case_contact.id,
- occurred_at: I18n.l(case_contact.occurred_at, format: :full, default: nil),
- casa_case: {
- id: case_contact.casa_case_id,
- case_number: case_contact.casa_case&.case_number
- },
- contact_types: case_contact.contact_types.map(&:name).join(", "),
- medium_type: case_contact.medium_type&.titleize,
- creator: {
- id: case_contact.creator_id,
- display_name: case_contact.creator&.display_name,
- email: case_contact.creator&.email,
- role: case_contact.creator&.role
- },
- contact_made: case_contact.contact_made,
- duration_minutes: case_contact.duration_minutes,
- contact_topics: case_contact.contact_topics.map(&:question),
- contact_topic_answers: case_contact.contact_topic_answers
- .reject { |a| a.value.blank? }
- .map { |a| {question: a.contact_topic&.question, value: a.value} },
- notes: case_contact.notes.presence,
- is_draft: !case_contact.active?,
- has_followup: requested_followup.present?,
- can_edit: policy.update?,
- can_destroy: policy.destroy?,
- edit_path: Rails.application.routes.url_helpers.edit_case_contact_path(case_contact),
- followup_id: requested_followup&.id
- }
- end
- end
-
- def filtered_records
- apply_additional_filters(raw_records.where(search_filter))
- end
-
- def apply_additional_filters(records)
- records = records.occurred_starting_at(additional_filters[:occurred_starting_at])
- records = records.occurred_ending_at(additional_filters[:occurred_ending_at])
- records = records.with_casa_case(Array(additional_filters[:casa_case_ids])) if additional_filters[:casa_case_ids].present?
- records = records.contact_type(Array(additional_filters[:contact_type_ids])) if additional_filters[:contact_type_ids].present?
- records = records.contact_medium(additional_filters[:contact_medium])
- records = records.contact_made(additional_filters[:contact_made])
- records = records.no_drafts(additional_filters[:no_drafts].to_i) if additional_filters[:no_drafts].present?
- records
- end
-
- def raw_records
- base_relation
- .joins("INNER JOIN users creators ON creators.id = case_contacts.creator_id")
- .left_joins(:casa_case)
- .includes(:casa_case, :contact_types, :contact_topics, :followups, :creator, contact_topic_answers: :contact_topic)
- .preload(:casa_org, :creator_casa_org)
- .order(order_clause)
- .order(:id)
- end
-
- def search_filter
- return "TRUE" if search_term.blank?
-
- ilike_fields = %w[
- creators.display_name
- creators.email
- casa_cases.case_number
- case_contacts.notes
- ]
-
- ilike_clauses = ilike_fields.map { |field| "#{field} ILIKE ?" }.join(" OR ")
- contact_type_clause = "case_contacts.id IN (#{contact_type_search_subquery})"
-
- full_clause = "#{ilike_clauses} OR #{contact_type_clause}"
- [full_clause, ilike_fields.count.times.map { "%#{search_term}%" }].flatten
- end
-
- def contact_type_search_subquery
- @contact_type_search_subquery ||= lambda {
- return "SELECT NULL WHERE FALSE" if search_term.blank?
-
- CaseContact
- .select("DISTINCT case_contacts.id")
- .joins(case_contact_contact_types: :contact_type)
- .where("contact_types.name ILIKE ?", "%#{search_term}%")
- .to_sql
- }.call
- end
-
- def order_clause
- @order_clause ||= build_order_clause
- end
-end
diff --git a/app/datatables/reimbursement_datatable.rb b/app/datatables/reimbursement_datatable.rb
deleted file mode 100644
index edf82ffb31..0000000000
--- a/app/datatables/reimbursement_datatable.rb
+++ /dev/null
@@ -1,62 +0,0 @@
-class ReimbursementDatatable < ApplicationDatatable
- ORDERABLE_FIELDS = %w[
- display_name
- case_number
- occurred_at
- miles_driven
- ].freeze
-
- private
-
- def data
- records.map do |case_contact|
- {
- casa_case: {
- id: case_contact.casa_case.id,
- case_number: case_contact.casa_case.case_number
- },
- complete: case_contact.reimbursement_complete,
- contact_types: case_contact_types(case_contact),
- id: case_contact.id,
- mark_as_complete_path: mark_as_complete_path(case_contact),
- miles_driven: case_contact.miles_driven,
- occurred_at: case_contact.occurred_at,
- volunteer: {
- address: case_contact.creator.address&.content,
- display_name: case_contact.creator.display_name,
- email: case_contact.creator.email,
- id: case_contact.creator.id
- }
- }
- end
- end
-
- def case_contact_types(case_contact)
- case_contact.contact_types.map do |contact_type|
- {
- name: contact_type.name,
- group_name: contact_type.contact_type_group.name
- }
- end
- end
-
- def mark_as_complete_path(case_contact)
- "/reimbursements/#{case_contact.id}/mark_as_complete"
- end
-
- def raw_records
- base_relation
- .order(order_clause)
- .select(
- <<-SQL
- case_contacts.*,
- users.display_name AS volunteer
- SQL
- )
- .joins(:creator)
- end
-
- def order_clause
- @order_clause ||= build_order_clause
- end
-end
diff --git a/app/datatables/supervisor_datatable.rb b/app/datatables/supervisor_datatable.rb
deleted file mode 100644
index 62425a202d..0000000000
--- a/app/datatables/supervisor_datatable.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-class SupervisorDatatable < ApplicationDatatable
- ORDERABLE_FIELDS = %w[
- active
- display_name
- email
- ]
-
- private
-
- def data
- records.map do |supervisor|
- {
- id: supervisor.id,
- active: supervisor.active?,
- display_name: supervisor.display_name,
- email: supervisor.email,
- volunteer_assignments: supervisor.volunteers.count,
- transitions_volunteers: supervisor.volunteers_serving_transition_aged_youth,
- no_attempt_for_two_weeks: supervisor.no_attempt_for_two_weeks
- }
- end
- end
-
- def raw_records
- base_relation.order(order_clause, :id)
- end
-
- def filtered_records
- raw_records.where(active_filter)
- end
-
- def active_filter
- @active_filter ||=
- lambda do
- filter = additional_filters[:active]
-
- bool_filter filter do
- ["users.active = ?", filter[0]]
- end
- end.call
- end
-
- def order_clause
- @order_clause ||=
- build_order_clause || Arel.sql("COALESCE(users.display_name, users.email) #{order_direction}")
- end
-end
diff --git a/app/datatables/volunteer_datatable.rb b/app/datatables/volunteer_datatable.rb
index ffb8c7de5e..f84c156023 100644
--- a/app/datatables/volunteer_datatable.rb
+++ b/app/datatables/volunteer_datatable.rb
@@ -10,6 +10,21 @@ class VolunteerDatatable < ApplicationDatatable
hours_spent_in_days
]
+ # Server-side entry point for the migrated (bespoke Pagy) index. Reuses the same
+ # filter/search/order SQL as the DataTables JSON path; the controller maps plain GET
+ # params into the DataTables param shape. Preloads languages for the extra-languages column.
+ def index_relation
+ filtered_records.includes(:languages)
+ end
+
+ # Count for the migrated index's Pagy bar. filtered_records carries a custom SELECT
+ # (COALESCE aliases) and an ORDER by one of those aliases, neither of which AR's COUNT
+ # can wrap, so strip them and count distinct volunteers (distinct guards the
+ # extra-languages join fan-out).
+ def index_count
+ index_relation.except(:select, :order, :includes).distinct.count("users.id")
+ end
+
private
def data
@@ -18,7 +33,7 @@ def data
active: volunteer.active?,
casa_cases: volunteer.casa_cases.map { |cc| {id: cc.id, case_number: cc.case_number} },
contacts_made_in_past_days: volunteer.contacts_made_in_past_days,
- display_name: volunteer.display_name,
+ display_name: NamePresentation.strip_honorific(volunteer.display_name),
email: volunteer.email,
has_transition_aged_youth_cases: volunteer.has_transition_aged_youth_cases?,
id: volunteer.id,
@@ -27,7 +42,7 @@ def data
case_id: volunteer.most_recent_attempt_case_id,
occurred_at: I18n.l(volunteer.most_recent_attempt_occurred_at, format: :full, default: nil)
},
- supervisor: {id: volunteer.supervisor_id, name: volunteer.supervisor_name},
+ supervisor: {id: volunteer.supervisor_id, name: NamePresentation.strip_honorific(volunteer.supervisor_name)},
hours_spent_in_days: volunteer.hours_spent_in_days(30),
extra_languages: volunteer.languages&.map { |lang| {id: lang.id, name: lang.name} }
}
diff --git a/app/decorators/casa_case_decorator.rb b/app/decorators/casa_case_decorator.rb
index 096944b00c..ec84c9c0d4 100644
--- a/app/decorators/casa_case_decorator.rb
+++ b/app/decorators/casa_case_decorator.rb
@@ -50,7 +50,7 @@ def date_in_care
def duration_in_care
return nil unless object.date_in_care
- "(#{time_ago_in_words(object.date_in_care)} ago)"
+ "In care for #{time_ago_in_words(object.date_in_care)}"
end
def calendar_next_court_date
@@ -71,6 +71,12 @@ def formatted_updated_at
I18n.l(object.updated_at, format: :standard, default: nil)
end
+ def formatted_next_court_date
+ upcoming = object.court_dates.select { |court_date| court_date.date.to_date >= Date.current }.min_by(&:date)
+ return nil unless upcoming
+ I18n.l(upcoming.date, format: :full, default: nil)
+ end
+
def inactive_class
(!object.active) ? "table-secondary" : ""
end
diff --git a/app/decorators/case_contact_decorator.rb b/app/decorators/case_contact_decorator.rb
index 0f1f6c915f..c8d5d1aa15 100644
--- a/app/decorators/case_contact_decorator.rb
+++ b/app/decorators/case_contact_decorator.rb
@@ -97,6 +97,19 @@ def medium_icon_classes
end
end
+ # Bootstrap Icons variant of medium_icon_classes, for the Tailwind (casa_app)
+ # views, which load bootstrap-icons rather than the legacy LineIcons font.
+ def medium_icon
+ case object.medium_type
+ when CaseContact::IN_PERSON then "bi bi-people"
+ when CaseContact::TEXT_EMAIL then "bi bi-envelope"
+ when CaseContact::VIDEO then "bi bi-camera-video"
+ when CaseContact::VOICE_ONLY then "bi bi-telephone"
+ when CaseContact::LETTER then "bi bi-envelope-paper"
+ else "bi bi-question-circle"
+ end
+ end
+
def contact_groups
groups = contact_groups_with_types.keys
if groups.count > 0
@@ -130,20 +143,24 @@ def address_of_volunteer
end
end
+ # Structured parts to prefill the reimbursement address fields: prefer the volunteer's saved
+ # structured Address; otherwise put the whole known address (snapshot or legacy content) into
+ # line 1 so nothing is lost when it can't be split into parts.
+ def volunteer_address_parts
+ address = volunteer&.address
+ if address&.structured?
+ Address::STRUCTURED_FIELDS.index_with { |field| address.public_send(field) }
+ else
+ {line_1: address_of_volunteer, line_2: nil, city: nil, state: nil, zip: nil}
+ end
+ end
+
def ambiguous_volunteer_address_message
"There are two or more volunteers assigned to this case and you are trying to set the address for both of them. This is not currently possible."
end
def form_title
- active? ? "Editing Existing Case Contact" : "Record New Case Contact"
- end
-
- def form_page_notes
- {
- details: nil,
- notes: "This question will be included in the court report for your assigned foster youth. Your response here will appear on the generated report for this case. To download the report, head to 'Group Actions'.",
- expenses: nil
- }
+ active? ? "Editing existing case contact" : "Record new case contact"
end
def form_updated_message
diff --git a/app/decorators/case_contacts_decorator.rb b/app/decorators/case_contacts_decorator.rb
index 89cebae638..fb706fa20e 100644
--- a/app/decorators/case_contacts_decorator.rb
+++ b/app/decorators/case_contacts_decorator.rb
@@ -3,7 +3,7 @@ def display_case_number(casa_case_id)
casa_case = casa_cases[casa_case_id]
if casa_cases[casa_case&.id]&.case_number.present?
- "#{casa_case.decorate.transition_aged_youth_icon} #{casa_case.case_number}"
+ casa_case.case_number
else
""
end
diff --git a/app/decorators/contact_type_decorator.rb b/app/decorators/contact_type_decorator.rb
index 855baa57d3..82315e75b2 100644
--- a/app/decorators/contact_type_decorator.rb
+++ b/app/decorators/contact_type_decorator.rb
@@ -11,8 +11,25 @@ def hash_for_multi_select_with_cases(casa_case_ids)
end
def last_time_used_with_cases(casa_case_ids)
- last_contact = CaseContact.joins(:contact_types).where(casa_case_id: casa_case_ids, contact_types: {id: object.id}).order(occurred_at: :desc).first
+ last_contact = last_contact_with_cases(casa_case_ids)
last_contact&.occurred_at.blank? ? "never" : "#{time_ago_in_words(last_contact.occurred_at)} ago"
end
+
+ # Labeled recency hint for the contact-type checkboxes. Returns nil when this type has never
+ # been logged for the case(s) so the form can omit the line rather than show a bare "never".
+ def last_logged_hint_with_cases(casa_case_ids)
+ last_contact = last_contact_with_cases(casa_case_ids)
+ return if last_contact&.occurred_at.blank?
+
+ "Last logged #{time_ago_in_words(last_contact.occurred_at)} ago"
+ end
+
+ private
+
+ def last_contact_with_cases(casa_case_ids)
+ CaseContact.joins(:contact_types)
+ .where(casa_case_id: casa_case_ids, contact_types: {id: object.id})
+ .order(occurred_at: :desc).first
+ end
end
diff --git a/app/helpers/banner_helper.rb b/app/helpers/banner_helper.rb
index d358a7de3d..7c7cfd93a7 100644
--- a/app/helpers/banner_helper.rb
+++ b/app/helpers/banner_helper.rb
@@ -1,7 +1,7 @@
module BannerHelper
def conditionally_add_hidden_class(current_banner_is_active)
unless current_banner_is_active && current_organization.has_alternate_active_banner?(@banner.id)
- "d-none"
+ "hidden"
end
end
diff --git a/app/helpers/design_system_helper.rb b/app/helpers/design_system_helper.rb
new file mode 100644
index 0000000000..f659e65e40
--- /dev/null
+++ b/app/helpers/design_system_helper.rb
@@ -0,0 +1,53 @@
+module DesignSystemHelper
+ # Strip an honorific prefix (Mr./Mrs./...) from a name for display. Use at any
+ # existing `.display_name` call site that shows a person's name.
+ def formatted_name(name)
+ NamePresentation.strip_honorific(name.to_s)
+ end
+
+ # A person's display name (honorific-free) with an email fallback. Prefer this for
+ # new UI where you have the user object.
+ def display_person(user)
+ formatted_name(user.display_name.presence || user.email)
+ end
+
+ # Two-letter initials for avatars: first + last name, ignoring honorific prefixes.
+ # Falls back to a single letter for one-word names / emails.
+ def avatar_initials(name)
+ tokens = NamePresentation.strip_honorific(name.to_s).gsub(/[^a-zA-Z ]/, " ").split
+ initials =
+ if tokens.size >= 2
+ "#{tokens.first[0]}#{tokens.last[0]}"
+ else
+ tokens.first.to_s[0, 1]
+ end
+ initials.upcase.presence || "?"
+ end
+
+ # Single source of truth for design-system button styling. These used to be
+ # copy-pasted class strings across views, which is how the variants drifted
+ # (mismatched heights, ad hoc padding). Repoint every button here instead.
+ #
+ # All variants are the same size by construction: the fixed `h-10` (40px) height
+ # token means `box-sizing: border-box` absorbs the outlined variant's 1px border,
+ # so a filled button (no border) and the outlined secondary render identically
+ # tall. Do NOT add a `border border-transparent` compensation to the filled
+ # variants; the height token already handles it.
+ #
+ # Keep every class a literal string per variant: Tailwind's source scan
+ # (`app/helpers/**/*.rb` is a @source) only sees class names written out in full.
+ # Never build them by interpolation (e.g. "bg-#{color}-600") or they won't compile.
+ def button_classes(variant = :primary)
+ base = "inline-flex h-10 items-center justify-center gap-2 rounded-lg px-4 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60"
+ variant_classes =
+ case variant
+ when :primary then "bg-brand-600 font-semibold text-white hover:bg-brand-700 focus-visible:ring-brand-500"
+ when :secondary then "border border-slate-200 bg-white font-medium text-slate-700 hover:bg-slate-50 focus-visible:ring-brand-500"
+ when :danger then "bg-rose-600 font-semibold text-white hover:bg-rose-700 focus-visible:ring-rose-500"
+ when :danger_outline then "border border-rose-200 bg-white font-medium text-rose-700 hover:bg-rose-50 focus-visible:ring-rose-500"
+ when :success then "bg-emerald-700 font-semibold text-white hover:bg-emerald-800 focus-visible:ring-emerald-500"
+ else raise ArgumentError, "unknown button variant: #{variant.inspect}"
+ end
+ "#{base} #{variant_classes}"
+ end
+end
diff --git a/app/helpers/metrics_helper.rb b/app/helpers/metrics_helper.rb
new file mode 100644
index 0000000000..b4f9e7f5fe
--- /dev/null
+++ b/app/helpers/metrics_helper.rb
@@ -0,0 +1,231 @@
+module MetricsHelper
+ SERIES_COLORS = %w[#4f46e5 #059669 #d97706 #e11d48].freeze
+ SERIES_DASH = ["", "7 4", "1.5 4", "10 4 1.5 4"].freeze
+ SERIES_SHAPE = %i[circle square triangle diamond].freeze
+ HEATMAP_RAMP = %w[#eef2ff #c7d2fe #a5b4fc #818cf8 #6366f1 #4338ca].freeze
+ DAY_LABELS = %w[Sun Mon Tue Wed Thu Fri Sat].freeze
+
+ def metric_tiles(items)
+ tag.div(safe_join(items.map { |item| metric_tile(item) }), class: "grid grid-cols-2 gap-3 sm:flex sm:flex-wrap")
+ end
+
+ def metric_empty_state(heading, body)
+ tag.div(class: "rounded-2xl border border-dashed border-slate-300 bg-white px-6 py-10 text-center") do
+ safe_join([
+ tag.div(empty_chart_icon, class: "mx-auto mb-3 flex justify-center text-slate-400"),
+ tag.p(heading, class: "text-[15px] font-bold text-slate-900"),
+ tag.p(body, class: "mx-auto mt-1 max-w-[46ch] text-sm text-slate-600")
+ ])
+ end
+ end
+
+ def metric_legend(series)
+ items = series.each_with_index.map do |ser, i|
+ tag.span(class: "inline-flex items-center gap-1.5 text-[13px] text-slate-600") do
+ safe_join([line_key_svg(i), tag.span(ser[:name])])
+ end
+ end
+ tag.div(safe_join(items), class: "mt-3 flex flex-wrap gap-x-4 gap-y-2")
+ end
+
+ def metric_line_chart(id, labels, series, title:, desc:)
+ svg, config = build_line_chart(id, labels, series, title, desc)
+ tag.div(
+ safe_join([
+ raw(svg),
+ tag.div("", class: "pointer-events-none absolute left-0 top-0 z-10 rounded-lg bg-slate-900 px-2.5 py-2 text-xs text-white opacity-0 shadow-lg transition-opacity", data: {"chart-hover-target": "tip"})
+ ]),
+ class: "relative",
+ data: {controller: "chart-hover", "chart-hover-config-value": config.to_json}
+ )
+ end
+
+ def metric_range_filter(active, base_path)
+ presets = {3 => "Last 3 months", 6 => "Last 6 months", 12 => "Last 12 months"}
+ links = presets.map do |months, label|
+ current = (months == active)
+ classes = "rounded-lg border px-3 py-1.5 text-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 " +
+ (current ? "border-brand-200 bg-brand-50 text-brand-700" : "border-slate-200 bg-white text-slate-600 hover:bg-slate-50")
+ link_to(label, "#{base_path}?range=#{months}", class: classes, "aria-current": (current ? "page" : nil))
+ end
+ tag.div(safe_join([tag.span("Range", class: "mr-1 text-[13px] text-slate-500")] + links),
+ class: "mt-6 mb-6 flex flex-wrap items-center gap-2", role: "group", "aria-label": "Date range")
+ end
+
+ def metric_data_table(labels, series, caption:, foot: nil, footnote: nil)
+ th = "px-2.5 py-1.5 text-right text-xs font-semibold text-slate-500"
+ th_row = "px-2.5 py-1.5 text-left text-xs font-semibold text-slate-700"
+ td = "px-2.5 py-1.5 text-right text-xs tabular-nums text-slate-700"
+ header = tag.tr(class: "border-b border-slate-200") do
+ safe_join([tag.th("Month", scope: "col", class: "#{th} text-left")] +
+ series.map { |ser| tag.th(ser[:name], scope: "col", class: th) })
+ end
+ rows = labels.each_with_index.map do |lab, i|
+ tag.tr(class: (i.zero? ? "" : "border-t border-slate-50")) do
+ safe_join([tag.th(lab, scope: "row", class: th_row)] +
+ series.map { |ser| tag.td(ser[:data][i], class: td) })
+ end
+ end
+ foot_html = if foot
+ tag.tfoot(tag.tr(safe_join(
+ [tag.th(foot[:label], scope: "row", class: "#{th_row} border-t-2 border-slate-200 text-slate-900")] +
+ foot[:cells].map { |c| tag.td(c, class: "#{td} border-t-2 border-slate-200 font-bold text-slate-900") }
+ )))
+ else
+ "".html_safe
+ end
+ table = tag.table(class: "w-full") do
+ safe_join([tag.caption(caption, class: "sr-only"), tag.thead(header), tag.tbody(safe_join(rows)), foot_html])
+ end
+ tag.details(class: "mt-3 border-t border-slate-100 pt-2.5") do
+ safe_join([
+ tag.summary("View as table", class: "w-max cursor-pointer text-[13px] font-medium text-brand-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"),
+ tag.div(table, class: "mt-2.5 overflow-x-auto"),
+ (footnote ? tag.p(footnote, class: "mt-2 text-[11px] text-slate-500") : "".html_safe)
+ ])
+ end
+ end
+
+ def metric_heatmap(grid, max)
+ hours = (0..23).to_a
+ header = tag.tr do
+ safe_join([tag.th(tag.span("Day of week", class: "sr-only"), scope: "col", class: "sticky left-0 z-10 bg-white px-1 py-1")] +
+ hours.map { |hr| tag.th(hr, scope: "col", class: "px-1 py-1 text-center text-[10px] font-medium text-slate-500") })
+ end
+ rows = DAY_LABELS.each_with_index.map do |day, di|
+ cells = hours.map do |hr|
+ count = grid[[di, hr]] || 0
+ tag.td(count.positive? ? count : "",
+ style: "background:#{heat_color(count, max)};color:#{heat_ink(count, max)}",
+ class: "w-[30px] min-w-[30px] rounded border-2 border-white py-1 text-center text-[11px]",
+ title: "#{day} #{hr}:00, #{count} #{"contact".pluralize(count)}")
+ end
+ tag.tr(safe_join([tag.th(day, scope: "row", class: "sticky left-0 z-10 bg-white px-1 py-1 pr-2.5 text-right text-[11px] font-semibold text-slate-700")] + cells))
+ end
+ tag.div(class: "overflow-x-auto") do
+ tag.table(class: "border-collapse") do
+ safe_join([
+ tag.caption("Case contacts created by day of week (rows) and hour of day (columns, 0 to 23). Cell shade and number both encode the count.", class: "sr-only"),
+ tag.thead(header),
+ tag.tbody(safe_join(rows))
+ ])
+ end
+ end
+ end
+
+ private
+
+ def metric_tile(item)
+ value = item[:value]
+ value_html =
+ if value.nil?
+ tag.div("No data", class: "pt-1 text-[15px] font-semibold leading-tight text-slate-600")
+ elsif value.zero?
+ tag.div("0", class: "text-2xl font-bold leading-none text-slate-500")
+ else
+ tag.div(value, class: "text-2xl font-bold leading-none text-slate-900")
+ end
+ tag.div(class: "rounded-xl bg-slate-50 px-3.5 py-3 sm:min-w-[130px] sm:flex-1") do
+ safe_join([
+ value_html,
+ tag.div(item[:label], class: "mt-1 text-sm text-slate-600"),
+ tag.div(item[:sub], class: "text-[11px] text-slate-500")
+ ])
+ end
+ end
+
+ def build_line_chart(id, labels, series, title, desc)
+ w = 720
+ h = 300
+ pad_l = 42
+ pad_r = 40
+ pad_t = 16
+ pad_b = 34
+ plot_w = w - pad_l - pad_r
+ plot_h = h - pad_t - pad_b
+ ymax = nice_ceiling(series.flat_map { |s| s[:data] }.max.to_i)
+ n = labels.size
+ xx = ->(i) { (pad_l + plot_w * i.to_f / [n - 1, 1].max).round(1) }
+ yy = ->(v) { (pad_t + plot_h * (1 - v.to_f / ymax)).round(1) }
+ out = %(
"
+ config = {
+ plotTop: pad_t,
+ plotBottom: pad_t + plot_h,
+ xs: (0...n).map { |i| xx.call(i) },
+ labels: labels,
+ series: series.each_with_index.map { |ser, si| {name: ser[:name], color: series_color(si), values: ser[:data], ys: ser[:data].map { |v| yy.call(v) }} }
+ }
+ [out, config]
+ end
+
+ def line_key_svg(i)
+ color = series_color(i)
+ dash = series_dash(i)
+ dash_attr = dash.empty? ? "" : %( stroke-dasharray="#{dash}")
+ raw(%(
))
+ end
+
+ def metric_marker(shape, cx, cy, color, r = 3.6)
+ case shape
+ when :circle
+ %(
)
+ when :square
+ s = r * 1.8
+ %(
)
+ when :triangle
+ s = r * 2.2
+ %(
)
+ else
+ s = r * 1.5
+ %(
)
+ end
+ end
+
+ def nice_ceiling(v)
+ return 5 if v <= 5
+ mag = 10**Math.log10(v).floor
+ [1, 2, 2.5, 5, 10].map { |f| f * mag }.find { |c| c >= v } || 10 * mag
+ end
+
+ def heat_color(count, max)
+ return "#f8fafc" if count <= 0 || max <= 0
+ idx = [((count.to_f / max) * (HEATMAP_RAMP.size - 1)).round, HEATMAP_RAMP.size - 1].min
+ HEATMAP_RAMP[idx]
+ end
+
+ def heat_ink(count, max)
+ (max.positive? && count.to_f / max > 0.55) ? "#ffffff" : "#334155"
+ end
+
+ def empty_chart_icon
+ raw(%(
))
+ end
+
+ def series_color(i) = SERIES_COLORS[i % SERIES_COLORS.size]
+
+ def series_dash(i) = SERIES_DASH[i % SERIES_DASH.size]
+end
diff --git a/app/helpers/table_helper.rb b/app/helpers/table_helper.rb
new file mode 100644
index 0000000000..b3d6c4e060
--- /dev/null
+++ b/app/helpers/table_helper.rb
@@ -0,0 +1,29 @@
+module TableHelper
+ # A sortable column header: an
containing a link that toggles the
+ # sort direction, plus a double-caret indicator whose active half is brand-coloured.
+ # Sorting is server-side, so the link preserves the current query (filters) and
+ # resets the page. `column` must be whitelisted by the controller.
+ def sortable_header(label, column, sort:, direction:)
+ active = column.to_s == sort.to_s
+ state = active ? direction : "none"
+ aria = {"asc" => "ascending", "desc" => "descending"}.fetch(state, "none")
+ next_direction = (active && direction == "asc") ? "desc" : "asc"
+ href = url_for(request.query_parameters.merge("sort" => column.to_s, "direction" => next_direction).except("page"))
+ link_class = "inline-flex items-center rounded text-xs font-semibold #{active ? "text-slate-900" : "text-slate-600"} hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
+ content_tag(:th, class: "px-4 py-3 text-left align-top", "aria-sort": aria) do
+ link_to(href, class: link_class) { safe_join([label, sort_caret(state)]) }
+ end
+ end
+
+ def sort_caret(state)
+ up = caret_color(state, "asc")
+ down = caret_color(state, "desc")
+ raw(%())
+ end
+
+ # Brand for the active half, light for the other half of a sorted column, mid when unsorted.
+ def caret_color(state, half)
+ return "#94a3b8" if state == "none"
+ (state == half) ? "#4f46e5" : "#cbd5e1"
+ end
+end
diff --git a/app/javascript/__tests__/add_to_calendar_controller.test.js b/app/javascript/__tests__/add_to_calendar_controller.test.js
new file mode 100644
index 0000000000..80a3dda870
--- /dev/null
+++ b/app/javascript/__tests__/add_to_calendar_controller.test.js
@@ -0,0 +1,50 @@
+/* eslint-env jest */
+/**
+ * @jest-environment jsdom
+ */
+import { Application } from '@hotwired/stimulus'
+import AddToCalendarController from '../controllers/add_to_calendar_controller'
+
+// The web component itself is browser-only; stub it out so importing the
+// controller has no side effects and createElement makes an inert element.
+jest.mock('add-to-calendar-button', () => ({}))
+
+describe('add_to_calendar_controller', () => {
+ let application
+
+ const mount = async (html) => {
+ document.body.innerHTML = html
+ application = Application.start()
+ application.register('add-to-calendar', AddToCalendarController)
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ }
+
+ afterEach(() => {
+ if (application) application.stop()
+ document.body.innerHTML = ''
+ })
+
+ test('replaces its content with a configured add-to-calendar-button', async () => {
+ await mount(`
+ stale content
+ `)
+
+ const host = document.querySelector('[data-controller="add-to-calendar"]')
+ const button = host.querySelector('add-to-calendar-button')
+
+ expect(button).not.toBeNull()
+ expect(host.textContent).not.toContain('stale content')
+ expect(button.getAttribute('name')).toBe('Court Hearing')
+ expect(button.getAttribute('startDate')).toBe('2025-11-15')
+ expect(button.getAttribute('endDate')).toBe('2025-11-15')
+ expect(button.getAttribute('description')).toBe('Court Hearing')
+ expect(button.getAttribute('options')).toBe("'Apple','Google','iCal','Microsoft365','Outlook.com','Yahoo'")
+ expect(button.getAttribute('timeZone')).toBe('currentBrowser')
+ expect(button.getAttribute('lightMode')).toBe('bodyScheme')
+ expect(button.title).toBe('Add to calendar')
+ })
+})
diff --git a/app/javascript/__tests__/dashboard.test.js b/app/javascript/__tests__/dashboard.test.js
deleted file mode 100644
index 3803cc4052..0000000000
--- a/app/javascript/__tests__/dashboard.test.js
+++ /dev/null
@@ -1,697 +0,0 @@
-/* eslint-env jest */
-/**
- * @jest-environment jsdom
- */
-
-import Swal from 'sweetalert2'
-import { defineCaseContactsTable } from '../src/dashboard'
-import { fireSwalFollowupAlert } from '../src/case_contact'
-jest.mock('sweetalert2', () => ({
- __esModule: true,
- default: { fire: jest.fn() }
-}))
-
-jest.mock('../src/case_contact', () => ({
- fireSwalFollowupAlert: jest.fn()
-}))
-
-// Mock DataTable
-const mockDataTable = jest.fn()
-$.fn.DataTable = mockDataTable
-
-describe('defineCaseContactsTable', () => {
- let tableElement
-
- beforeEach(() => {
- // Reset mocks
- mockDataTable.mockClear()
-
- // Set up DOM
- document.body.innerHTML = `
-
- `
-
- tableElement = $('table#case_contacts')
- })
-
- describe('DataTable initialization', () => {
- it('initializes DataTable on the case_contacts table', () => {
- defineCaseContactsTable()
-
- expect(mockDataTable).toHaveBeenCalledTimes(1)
- expect(mockDataTable.mock.instances[0][0]).toBe(tableElement[0])
- })
-
- it('configures DataTable with server-side processing', () => {
- defineCaseContactsTable()
-
- const config = mockDataTable.mock.calls[0][0]
-
- expect(config.serverSide).toBe(true)
- expect(config.processing).toBe(true)
- expect(config.searching).toBe(true)
- })
-
- it('disables autoWidth so columns do not expand to oversized fixed widths', () => {
- defineCaseContactsTable()
-
- const config = mockDataTable.mock.calls[0][0]
-
- expect(config.autoWidth).toBe(false)
- expect(config.scrollX).toBeUndefined()
- })
-
- it('sets default sort to Date column descending', () => {
- defineCaseContactsTable()
-
- const config = mockDataTable.mock.calls[0][0]
-
- expect(config.order).toEqual([[2, 'desc']])
- })
-
- it('disables ordering on bell, chevron, and ellipsis columns', () => {
- defineCaseContactsTable()
-
- const config = mockDataTable.mock.calls[0][0]
-
- expect(config.columnDefs).toEqual([
- { orderable: false, targets: [0, 1, 10] }
- ])
- })
- })
-
- describe('AJAX configuration', () => {
- it('uses the data-source URL from the table', () => {
- defineCaseContactsTable()
-
- const config = mockDataTable.mock.calls[0][0]
-
- expect(config.ajax.url).toBe('/case_contacts/new_design/datatable.json')
- expect(config.ajax.type).toBe('POST')
- expect(config.ajax.dataType).toBe('json')
- })
-
- it('includes error handler for AJAX requests', () => {
- const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
-
- defineCaseContactsTable()
-
- const config = mockDataTable.mock.calls[0][0]
- const mockError = 'Network error'
- const mockCode = 500
-
- config.ajax.error({}, mockError, mockCode)
-
- expect(consoleErrorSpy).toHaveBeenCalledWith('DataTable error:', mockError, mockCode)
-
- consoleErrorSpy.mockRestore()
- })
- })
-
- describe('column configurations', () => {
- let columns
-
- beforeEach(() => {
- defineCaseContactsTable()
- columns = mockDataTable.mock.calls[0][0].columns
- })
-
- it('configures 11 columns', () => {
- expect(columns).toHaveLength(11)
- })
-
- describe('Bell icon column (index 0)', () => {
- it('is not orderable or searchable', () => {
- expect(columns[0].orderable).toBe(false)
- expect(columns[0].searchable).toBe(false)
- })
-
- it('renders filled bell icon when has_followup is "true"', () => {
- const rendered = columns[0].render('true', 'display', {})
-
- expect(rendered).toBe('')
- })
-
- it('renders faded bell icon when has_followup is "false"', () => {
- const rendered = columns[0].render('false', 'display', {})
-
- expect(rendered).toBe('')
- })
- })
-
- describe('Chevron icon column (index 1)', () => {
- it('is not orderable or searchable', () => {
- expect(columns[1].orderable).toBe(false)
- expect(columns[1].searchable).toBe(false)
- })
-
- it('renders chevron-down icon as an accessible button', () => {
- const rendered = columns[1].render(null, 'display', {})
-
- expect(rendered).toBe('')
- })
- })
-
- describe('Date column (index 2)', () => {
- it('uses occurred_at data field', () => {
- expect(columns[2].data).toBe('occurred_at')
- expect(columns[2].name).toBe('occurred_at')
- })
-
- it('renders date string or empty string', () => {
- expect(columns[2].render('January 15, 2024')).toBe('January 15, 2024')
- expect(columns[2].render(null)).toBe('')
- expect(columns[2].render('')).toBe('')
- })
- })
-
- describe('Case column (index 3)', () => {
- it('is not orderable', () => {
- expect(columns[3].orderable).toBe(false)
- })
-
- it('renders link to casa_case when data exists', () => {
- const data = { id: '123', case_number: 'CASA-2024-001' }
- const rendered = columns[3].render(data, 'display', {})
-
- expect(rendered).toBe('CASA-2024-001')
- })
-
- it('renders empty string when casa_case is null', () => {
- expect(columns[3].render(null, 'display', {})).toBe('')
- })
-
- it('renders empty string when casa_case has no id', () => {
- const data = { id: null, case_number: 'CASA-2024-001' }
-
- expect(columns[3].render(data, 'display', {})).toBe('')
- })
- })
-
- describe('Relationship (Contact Types) column (index 4)', () => {
- it('is not orderable', () => {
- expect(columns[4].orderable).toBe(false)
- })
-
- it('renders contact types string', () => {
- expect(columns[4].render('Family, School')).toBe('Family, School')
- expect(columns[4].render(null)).toBe('')
- })
- })
-
- describe('Medium column (index 5)', () => {
- it('renders medium type', () => {
- expect(columns[5].render('In-person')).toBe('In-person')
- expect(columns[5].render('Text/Email')).toBe('Text/Email')
- expect(columns[5].render(null)).toBe('')
- })
- })
-
- describe('Created By column (index 6)', () => {
- it('is not orderable', () => {
- expect(columns[6].orderable).toBe(false)
- })
-
- it('renders empty string when creator is null', () => {
- expect(columns[6].render(null, 'display', {})).toBe('')
- })
-
- it('renders link to volunteer edit page for volunteers', () => {
- const data = {
- id: '456',
- display_name: 'John Doe',
- role: 'Volunteer'
- }
- const rendered = columns[6].render(data, 'display', {})
-
- expect(rendered).toBe('John Doe')
- })
-
- it('renders link to supervisor edit page for supervisors', () => {
- const data = {
- id: '789',
- display_name: 'Jane Smith',
- role: 'Supervisor'
- }
- const rendered = columns[6].render(data, 'display', {})
-
- expect(rendered).toBe('Jane Smith')
- })
-
- it('renders link to users edit page for casa admins', () => {
- const data = {
- id: '999',
- display_name: 'Admin User',
- role: 'Casa Admin'
- }
- const rendered = columns[6].render(data, 'display', {})
-
- expect(rendered).toBe('Admin User')
- })
- })
-
- describe('Contacted column (index 7)', () => {
- it('is not orderable', () => {
- expect(columns[7].orderable).toBe(false)
- })
-
- it('renders checkmark icon when contact was made', () => {
- const row = { contact_made: 'true', duration_minutes: null }
- const rendered = columns[7].render('true', 'display', row)
-
- expect(rendered).toContain('')
- })
-
- it('renders cross icon when contact was not made', () => {
- const row = { contact_made: 'false', duration_minutes: null }
- const rendered = columns[7].render('false', 'display', row)
-
- expect(rendered).toContain('')
- })
-
- it('includes formatted duration when present', () => {
- const row = { contact_made: 'true', duration_minutes: 90 }
- const rendered = columns[7].render('true', 'display', row)
-
- expect(rendered).toContain('(01:30)')
- })
-
- it('formats duration with leading zeros', () => {
- const row = { contact_made: 'true', duration_minutes: 5 }
- const rendered = columns[7].render('true', 'display', row)
-
- expect(rendered).toContain('(00:05)')
- })
-
- it('handles hours and minutes correctly', () => {
- const row = { contact_made: 'true', duration_minutes: 125 }
- const rendered = columns[7].render('true', 'display', row)
-
- expect(rendered).toContain('(02:05)')
- })
-
- it('does not include duration when not present', () => {
- const row = { contact_made: 'true', duration_minutes: null }
- const rendered = columns[7].render('true', 'display', row)
-
- expect(rendered).not.toContain('(')
- })
- })
-
- describe('Topics column (index 8)', () => {
- it('is not orderable', () => {
- expect(columns[8].orderable).toBe(false)
- })
-
- it('renders each topic as a pill badge', () => {
- const rendered = columns[8].render(['Topic 1', 'Topic 2'])
- expect(rendered).toContain('Topic 1')
- expect(rendered).toContain('Topic 2')
- })
-
- it('renders empty string when there are no topics', () => {
- expect(columns[8].render(null)).toBe('')
- expect(columns[8].render([])).toBe('')
- })
-
- it('shows only the first two topics when there are more than two', () => {
- const rendered = columns[8].render(['A', 'B', 'C', 'D'])
- expect(rendered).toContain('>A<')
- expect(rendered).toContain('>B<')
- expect(rendered).not.toContain('>C<')
- expect(rendered).not.toContain('>D<')
- })
-
- it('shows a +N More badge for overflow topics', () => {
- const rendered = columns[8].render(['A', 'B', 'C', 'D'])
- expect(rendered).toContain('+2 More')
- })
-
- it('does not show an overflow badge when there are two or fewer topics', () => {
- expect(columns[8].render(['A', 'B'])).not.toContain('More')
- expect(columns[8].render(['A'])).not.toContain('More')
- })
- })
-
- describe('Draft column (index 9)', () => {
- it('is not orderable', () => {
- expect(columns[9].orderable).toBe(false)
- })
-
- it('renders Draft badge when is_draft is true', () => {
- const rendered = columns[9].render(true, 'display', {})
-
- expect(rendered).toBe('Draft')
- })
-
- it('renders empty string when is_draft is false', () => {
- const rendered = columns[9].render(false, 'display', {})
-
- expect(rendered).toBe('')
- })
-
- it('handles string "true" as truthy', () => {
- const rendered = columns[9].render('true', 'display', {})
-
- expect(rendered).toBe('Draft')
- })
-
- it('handles string "false" as falsy (explicit check for "true")', () => {
- const rendered = columns[9].render('false', 'display', {})
-
- // With explicit check for === true || === "true", string "false" should not render badge
- expect(rendered).toBe('')
- })
-
- it('handles empty string as falsy', () => {
- const rendered = columns[9].render('', 'display', {})
-
- expect(rendered).toBe('')
- })
- })
-
- describe('Ellipsis menu column (index 10)', () => {
- it('is not orderable or searchable', () => {
- expect(columns[10].orderable).toBe(false)
- expect(columns[10].searchable).toBe(false)
- })
-
- it('renders a button toggle with aria-label containing the contact date', () => {
- const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'true', can_destroy: 'true', edit_path: '/case_contacts/1/edit', followup_id: '' }
- const rendered = columns[10].render(null, 'display', row)
-
- expect(rendered).toContain('class="fas fa-ellipsis-v"')
- expect(rendered).toContain('aria-label="Actions for case contact on July 01, 2024"')
- expect(rendered).toContain('type="button"')
- })
-
- it('renders the ellipsis icon as aria-hidden', () => {
- const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'true', can_destroy: 'true', edit_path: '/case_contacts/1/edit', followup_id: '' }
- const rendered = columns[10].render(null, 'display', row)
-
- expect(rendered).toContain('aria-hidden="true"')
- })
-
- it('renders Edit item when can_edit is "true"', () => {
- const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'true', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' }
- const rendered = columns[10].render(null, 'display', row)
-
- expect(rendered).toContain('href="/case_contacts/1/edit"')
- expect(rendered).toContain('Edit')
- })
-
- it('renders Edit as disabled when can_edit is "false"', () => {
- const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' }
- const rendered = columns[10].render(null, 'display', row)
-
- expect(rendered).toContain('Edit')
- expect(rendered).toContain('disabled')
- expect(rendered).toContain('aria-disabled="true"')
- expect(rendered).not.toContain('href="/case_contacts/1/edit"')
- })
-
- it('renders Delete item when can_destroy is "true"', () => {
- const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'true', edit_path: '/case_contacts/1/edit', followup_id: '' }
- const rendered = columns[10].render(null, 'display', row)
-
- expect(rendered).toContain('cc-delete-action')
- expect(rendered).toContain('data-id="1"')
- expect(rendered).toContain('Delete')
- })
-
- it('renders Delete as disabled when can_destroy is "false"', () => {
- const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' }
- const rendered = columns[10].render(null, 'display', row)
-
- expect(rendered).toContain('Delete')
- expect(rendered).toContain('disabled')
- expect(rendered).toContain('aria-disabled="true"')
- expect(rendered).not.toContain('cc-delete-action')
- })
-
- it('renders Set Reminder when followup_id is empty', () => {
- const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' }
- const rendered = columns[10].render(null, 'display', row)
-
- expect(rendered).toContain('cc-set-reminder-action')
- expect(rendered).toContain('Set Reminder')
- expect(rendered).not.toContain('Resolve Reminder')
- })
-
- it('renders Resolve Reminder when followup_id is present', () => {
- const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '42' }
- const rendered = columns[10].render(null, 'display', row)
-
- expect(rendered).toContain('cc-resolve-reminder-action')
- expect(rendered).toContain('data-followup-id="42"')
- expect(rendered).toContain('Resolve Reminder')
- expect(rendered).not.toContain('Set Reminder')
- })
-
- it('always renders the reminder menu item', () => {
- const row = { id: '1', occurred_at: 'July 01, 2024', can_edit: 'false', can_destroy: 'false', edit_path: '/case_contacts/1/edit', followup_id: '' }
- const rendered = columns[10].render(null, 'display', row)
-
- expect(rendered).toMatch(/Set Reminder|Resolve Reminder/)
- })
- })
- })
-
- describe('click handlers', () => {
- let mockAjaxReload
- let mockTableInstance
-
- const clickActionButton = (action, attrs = {}) => {
- const dataAttrs = Object.entries(attrs).map(([k, v]) => `data-${k}="${v}"`).join(' ')
- $('table#case_contacts tbody').append(
- ` | |
`
- )
- $(`.cc-${action}-action`).trigger('click')
- }
-
- beforeEach(() => {
- mockAjaxReload = jest.fn()
- mockTableInstance = { ajax: { reload: mockAjaxReload } }
- mockDataTable.mockReturnValue(mockTableInstance)
-
- // Add CSRF meta tag
- document.head.innerHTML = '
'
-
- defineCaseContactsTable()
- })
-
- afterEach(() => {
- Swal.fire.mockReset()
- fireSwalFollowupAlert.mockReset()
- })
-
- describe('Delete action', () => {
- it('shows a SweetAlert confirmation dialog when cc-delete-action is clicked', () => {
- Swal.fire.mockResolvedValue({ isConfirmed: false })
-
- clickActionButton('delete', { id: '42' })
-
- expect(Swal.fire).toHaveBeenCalled()
- })
-
- it('sends DELETE request when confirmed', async () => {
- Swal.fire.mockResolvedValue({ isConfirmed: true })
- const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success())
-
- clickActionButton('delete', { id: '42' })
-
- await Promise.resolve()
-
- expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({
- url: '/case_contacts/42',
- type: 'DELETE',
- headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' }
- }))
- expect(ajaxSpy.mock.calls[0][0]).not.toHaveProperty('dataType')
-
- ajaxSpy.mockRestore()
- })
-
- it('does not send DELETE request when cancelled', async () => {
- Swal.fire.mockResolvedValue({ isConfirmed: false })
- const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation()
-
- clickActionButton('delete', { id: '42' })
-
- await Promise.resolve()
-
- expect(ajaxSpy).not.toHaveBeenCalled()
-
- ajaxSpy.mockRestore()
- })
-
- it('reloads the DataTable without resetting pagination after successful delete', async () => {
- Swal.fire.mockResolvedValue({ isConfirmed: true })
- jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success())
-
- clickActionButton('delete', { id: '42' })
-
- await Promise.resolve()
-
- expect(mockAjaxReload).toHaveBeenCalledWith(null, false)
- })
- })
-
- describe('Set Reminder action', () => {
- it('calls fireSwalFollowupAlert when cc-set-reminder-action is clicked', () => {
- fireSwalFollowupAlert.mockResolvedValue({ isConfirmed: false })
-
- clickActionButton('set-reminder', { id: '5' })
-
- expect(fireSwalFollowupAlert).toHaveBeenCalled()
- })
-
- it('posts to the followups endpoint with CSRF header when confirmed without a note', async () => {
- fireSwalFollowupAlert.mockResolvedValue({ value: '', isConfirmed: true })
- const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success())
-
- clickActionButton('set-reminder', { id: '5' })
-
- await Promise.resolve()
-
- expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({
- url: '/case_contacts/5/followups',
- type: 'POST',
- data: {},
- headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' }
- }))
-
- ajaxSpy.mockRestore()
- })
-
- it('posts with note when confirmed with a note', async () => {
- fireSwalFollowupAlert.mockResolvedValue({ value: 'My note', isConfirmed: true })
- const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success())
-
- clickActionButton('set-reminder', { id: '5' })
-
- await Promise.resolve()
-
- expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({
- url: '/case_contacts/5/followups',
- type: 'POST',
- data: { note: 'My note' },
- headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' }
- }))
-
- ajaxSpy.mockRestore()
- })
-
- it('does not post when cancelled', async () => {
- fireSwalFollowupAlert.mockResolvedValue({ isConfirmed: false })
- const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation()
-
- clickActionButton('set-reminder', { id: '5' })
-
- await Promise.resolve()
-
- expect(ajaxSpy).not.toHaveBeenCalled()
-
- ajaxSpy.mockRestore()
- })
-
- it('reloads the DataTable without resetting pagination after creating a reminder', async () => {
- fireSwalFollowupAlert.mockResolvedValue({ value: '', isConfirmed: true })
- jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success())
-
- clickActionButton('set-reminder', { id: '5' })
-
- await Promise.resolve()
-
- expect(mockAjaxReload).toHaveBeenCalledWith(null, false)
- })
- })
-
- describe('Resolve Reminder action', () => {
- it('sends PATCH request when cc-resolve-reminder-action is clicked', () => {
- const ajaxSpy = jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success())
-
- clickActionButton('resolve-reminder', { id: '5', 'followup-id': '42' })
-
- expect(ajaxSpy).toHaveBeenCalledWith(expect.objectContaining({
- url: '/followups/42/resolve',
- type: 'PATCH',
- headers: { 'X-CSRF-Token': 'test-csrf-token', Accept: 'application/json' }
- }))
- expect(ajaxSpy.mock.calls[0][0]).not.toHaveProperty('dataType')
-
- ajaxSpy.mockRestore()
- })
-
- it('reloads the DataTable without resetting pagination after resolving a reminder', () => {
- jest.spyOn($, 'ajax').mockImplementation(({ success }) => success && success())
-
- clickActionButton('resolve-reminder', { id: '5', 'followup-id': '42' })
-
- expect(mockAjaxReload).toHaveBeenCalledWith(null, false)
- })
- })
- })
-
- describe('edge cases', () => {
- it('handles missing data-source attribute gracefully', () => {
- tableElement.removeAttr('data-source')
-
- expect(() => defineCaseContactsTable()).not.toThrow()
-
- const config = mockDataTable.mock.calls[0][0]
- expect(config.ajax.url).toBeUndefined()
- })
-
- it('handles table element not existing', () => {
- document.body.innerHTML = ''
-
- // Should not throw when table doesn't exist
- expect(() => defineCaseContactsTable()).not.toThrow()
- })
- })
-
- describe('DataTable integration', () => {
- it('passes all required configuration options', () => {
- defineCaseContactsTable()
-
- const config = mockDataTable.mock.calls[0][0]
-
- // Verify all critical config options are present
- expect(config).toHaveProperty('autoWidth')
- expect(config).toHaveProperty('searching')
- expect(config).toHaveProperty('processing')
- expect(config).toHaveProperty('serverSide')
- expect(config).toHaveProperty('order')
- expect(config).toHaveProperty('ajax')
- expect(config).toHaveProperty('columnDefs')
- expect(config).toHaveProperty('columns')
- })
-
- it('configures columns array matching table structure', () => {
- defineCaseContactsTable()
-
- const config = mockDataTable.mock.calls[0][0]
- const headerColumns = $('table#case_contacts thead th').length
-
- expect(config.columns.length).toBe(headerColumns)
- })
- })
-})
diff --git a/app/javascript/__tests__/local_storage_reset_controller.test.js b/app/javascript/__tests__/local_storage_reset_controller.test.js
new file mode 100644
index 0000000000..81cd32c7da
--- /dev/null
+++ b/app/javascript/__tests__/local_storage_reset_controller.test.js
@@ -0,0 +1,35 @@
+/* eslint-env jest */
+/**
+ * @jest-environment jsdom
+ */
+import { Application } from '@hotwired/stimulus'
+import LocalStorageResetController from '../controllers/local_storage_reset_controller'
+
+describe('local_storage_reset_controller', () => {
+ let application
+
+ const mount = async (html) => {
+ document.body.innerHTML = html
+ application = Application.start()
+ application.register('local-storage-reset', LocalStorageResetController)
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ }
+
+ afterEach(() => {
+ if (application) application.stop()
+ document.body.innerHTML = ''
+ window.localStorage.clear()
+ })
+
+ test('removes the configured key on connect', async () => {
+ window.localStorage.setItem('casa-contact-form', 'draft')
+ await mount('
')
+ expect(window.localStorage.getItem('casa-contact-form')).toBeNull()
+ })
+
+ test('leaves other keys untouched', async () => {
+ window.localStorage.setItem('keep-me', 'yes')
+ await mount('
')
+ expect(window.localStorage.getItem('keep-me')).toBe('yes')
+ })
+})
diff --git a/app/javascript/__tests__/nav_drawer_controller.test.js b/app/javascript/__tests__/nav_drawer_controller.test.js
new file mode 100644
index 0000000000..9a8b07fd4d
--- /dev/null
+++ b/app/javascript/__tests__/nav_drawer_controller.test.js
@@ -0,0 +1,71 @@
+/* eslint-env jest, browser */
+/**
+ * @jest-environment jsdom
+ */
+import { Application } from '@hotwired/stimulus'
+import NavDrawerController from '../controllers/nav_drawer_controller'
+
+describe('nav_drawer_controller', () => {
+ let application
+
+ const mount = async () => {
+ document.body.innerHTML = `
+
`
+ application = Application.start()
+ application.register('nav-drawer', NavDrawerController)
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ }
+
+ const els = () => ({
+ backdrop: document.querySelector('[data-nav-drawer-target="backdrop"]'),
+ sidebar: document.querySelector('[data-nav-drawer-target="sidebar"]'),
+ button: document.querySelector('[data-nav-drawer-target="button"]')
+ })
+
+ afterEach(() => {
+ if (application) application.stop()
+ document.body.innerHTML = ''
+ document.body.classList.remove('overflow-hidden')
+ })
+
+ test('the toggle button opens the drawer', async () => {
+ await mount()
+ const { backdrop, sidebar, button } = els()
+ button.click()
+ expect(sidebar.classList.contains('-translate-x-full')).toBe(false)
+ expect(backdrop.classList.contains('hidden')).toBe(false)
+ expect(button.getAttribute('aria-expanded')).toBe('true')
+ expect(document.body.classList.contains('overflow-hidden')).toBe(true)
+ })
+
+ test('the toggle button closes an open drawer', async () => {
+ await mount()
+ const { sidebar, button } = els()
+ button.click() // open
+ button.click() // close
+ expect(sidebar.classList.contains('-translate-x-full')).toBe(true)
+ expect(button.getAttribute('aria-expanded')).toBe('false')
+ expect(document.body.classList.contains('overflow-hidden')).toBe(false)
+ })
+
+ test('clicking the backdrop closes the drawer', async () => {
+ await mount()
+ const { backdrop, sidebar, button } = els()
+ button.click() // open
+ backdrop.click()
+ expect(sidebar.classList.contains('-translate-x-full')).toBe(true)
+ expect(backdrop.classList.contains('hidden')).toBe(true)
+ })
+
+ test('pressing Escape closes the drawer', async () => {
+ await mount()
+ const { sidebar, button } = els()
+ button.click() // open
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
+ expect(sidebar.classList.contains('-translate-x-full')).toBe(true)
+ })
+})
diff --git a/app/javascript/all_casa_admin.js b/app/javascript/all_casa_admin.js
index 6324ded939..b0723b43f9 100644
--- a/app/javascript/all_casa_admin.js
+++ b/app/javascript/all_casa_admin.js
@@ -1,4 +1,3 @@
require('./src/all_casa_admin/tables')
require('./src/all_casa_admin/patch_notes')
require('./src/session_timeout_poller.js')
-require('./src/display_app_metric.js')
diff --git a/app/javascript/application.js b/app/javascript/application.js
index 88f9aef317..5f20015a87 100644
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -19,20 +19,15 @@ require('./src/case_contact')
require('./src/case_emancipation')
require('./src/casa_case')
require('./src/new_casa_case')
-require('./src/dashboard')
require('./src/emancipations')
require('./src/import')
require('./src/password_confirmation')
require('./src/read_more')
-require('./src/reimbursements')
require('./src/reports')
require('./src/require_communication_preference')
require('./src/select')
require('./src/tooltip')
require('./src/time_zone')
require('./src/session_timeout_poller.js')
-require('./src/display_app_metric.js')
-require('./src/casa_org')
require('./src/sms_reactivation_toggle')
require('./src/validated_form')
-require('./src/learning_hours')
diff --git a/app/javascript/controllers/add_to_calendar_controller.js b/app/javascript/controllers/add_to_calendar_controller.js
new file mode 100644
index 0000000000..7f2eab374b
--- /dev/null
+++ b/app/javascript/controllers/add_to_calendar_controller.js
@@ -0,0 +1,29 @@
+import { Controller } from '@hotwired/stimulus'
+import 'add-to-calendar-button'
+
+// Hydrates an "Add to Calendar" web component from data values, so a court date
+// (or the next court date) can be saved to a personal calendar. This is the
+// Stimulus replacement for the legacy jQuery `div.cal-btn` scan
+// (src/add_to_calendar_button.js): it hydrates on connect, so it also survives
+// Turbo navigation, and each button owns its own data instead of a global sweep.
+export default class extends Controller {
+ static values = {
+ title: String,
+ start: String,
+ end: String,
+ tooltip: String
+ }
+
+ connect () {
+ const button = document.createElement('add-to-calendar-button')
+ button.setAttribute('name', this.titleValue)
+ button.setAttribute('startDate', this.startValue)
+ button.setAttribute('endDate', this.endValue)
+ button.setAttribute('description', this.titleValue)
+ button.setAttribute('options', "'Apple','Google','iCal','Microsoft365','Outlook.com','Yahoo'")
+ button.setAttribute('timeZone', 'currentBrowser')
+ button.setAttribute('lightMode', 'bodyScheme')
+ button.title = this.tooltipValue
+ this.element.replaceChildren(button)
+ }
+}
diff --git a/app/javascript/controllers/auto_submit_controller.js b/app/javascript/controllers/auto_submit_controller.js
new file mode 100644
index 0000000000..fb64329686
--- /dev/null
+++ b/app/javascript/controllers/auto_submit_controller.js
@@ -0,0 +1,9 @@
+import { Controller } from '@hotwired/stimulus'
+
+// Submits the form when a control changes (e.g. the table filter selects). Turbo Drive
+// keeps the navigation smooth, so filtering has no full-page flash.
+export default class extends Controller {
+ submit () {
+ this.element.requestSubmit()
+ }
+}
diff --git a/app/javascript/controllers/autosave_controller.js b/app/javascript/controllers/autosave_controller.js
index 5412001be0..e66d814164 100644
--- a/app/javascript/controllers/autosave_controller.js
+++ b/app/javascript/controllers/autosave_controller.js
@@ -17,8 +17,9 @@ export default class extends Controller {
static classes = ['goodAlert', 'badAlert']
connect () {
- this.visibleClass = 'visible'
- this.hiddenClass = 'invisible'
+ // display (not visibility) so a hidden status line reserves no space at the card's bottom
+ this.visibleClass = 'block'
+ this.hiddenClass = 'hidden'
this.save = debounce(this.save, this.delayValue).bind(this)
}
diff --git a/app/javascript/controllers/casa_nested_form_controller.js b/app/javascript/controllers/casa_nested_form_controller.js
index b742298b5d..4384b8eb38 100644
--- a/app/javascript/controllers/casa_nested_form_controller.js
+++ b/app/javascript/controllers/casa_nested_form_controller.js
@@ -25,6 +25,8 @@ export default class extends NestedForm {
}
}
+ static targets = ['confirmDialog']
+
connect () {
super.connect()
@@ -144,8 +146,31 @@ export default class extends NestedForm {
this.dispatchChangeEvent('remove')
}
- /* Destroys a record when removing the item (before submission). */
+ /* Delete button: a saved (autosaved) expense confirms through the design-system dialog before
+ the API delete; brand-new, unsaved rows are removed without a prompt. */
destroyAndRemove (e) {
+ const wrapper = e.target.closest(this.wrapperSelectorValue)
+ const recordId = this.getRecordId(wrapper)
+ if (wrapper.dataset.newRecord === 'false' && recordId.length > 0 && this.hasConfirmDialogTarget) {
+ e.preventDefault()
+ this.pendingEvent = e
+ this.confirmDialogTarget.showModal()
+ } else {
+ this.performDestroyAndRemove(e)
+ }
+ }
+
+ /* Confirm button inside the removal dialog. */
+ confirmRemove () {
+ this.confirmDialogTarget.close()
+ if (this.pendingEvent) {
+ this.performDestroyAndRemove(this.pendingEvent)
+ this.pendingEvent = null
+ }
+ }
+
+ /* Destroys a record when removing the item (before submission). */
+ performDestroyAndRemove (e) {
const wrapper = e.target.closest(this.wrapperSelectorValue)
const recordId = this.getRecordId(wrapper)
if (wrapper.dataset.newRecord === 'false' && (recordId.length > 0)) {
@@ -184,11 +209,4 @@ export default class extends NestedForm {
this.remove(e) // treat as typical removal
}
}
-
- confirmDestroyAndRemove (e) {
- const text = 'Are you sure you want to remove this item?'
- if (window.confirm(text)) {
- this.destroyAndRemove(e)
- }
- }
}
diff --git a/app/javascript/controllers/case_contact_form_controller.js b/app/javascript/controllers/case_contact_form_controller.js
index c712117db8..6add286ae0 100644
--- a/app/javascript/controllers/case_contact_form_controller.js
+++ b/app/javascript/controllers/case_contact_form_controller.js
@@ -68,17 +68,21 @@ export default class extends Controller {
clearMileage = () => {
this.milesDrivenTarget.value = 0
- this.volunteerAddressTarget.value = ''
+ // structured address = several volunteerAddress targets (line 1 / line 2 / city / state / zip)
+ this.volunteerAddressTargets.forEach(el => { el.value = '' })
}
setReimbursementFormVisibility = () => {
+ // Toggles Tailwind's `hidden` (display:none). The case-contact form is casadesign
+ // (Tailwind) now; Bootstrap's `d-none` is not defined there. This controller is used
+ // only by that form, so switching the class here is safe.
if (this.wantDrivingReimbursementTarget.checked) {
- this.reimbursementFormTarget.classList.remove('d-none')
+ this.reimbursementFormTarget.classList.remove('hidden')
this.expenseDestroyTargets.forEach(el => (el.value = '0'))
} else {
this.clearExpenses()
this.clearMileage()
- this.reimbursementFormTarget.classList.add('d-none')
+ this.reimbursementFormTarget.classList.add('hidden')
}
}
}
diff --git a/app/javascript/controllers/chart_hover_controller.js b/app/javascript/controllers/chart_hover_controller.js
new file mode 100644
index 0000000000..7524f93339
--- /dev/null
+++ b/app/javascript/controllers/chart_hover_controller.js
@@ -0,0 +1,107 @@
+import { Controller } from '@hotwired/stimulus'
+
+// Crosshair + tooltip for a server-rendered SVG line chart. Reads the chart
+// geometry from the config value; every value is also in the table twin, so this
+// is progressive enhancement, not the only way to read the data.
+export default class extends Controller {
+ static values = { config: Object }
+ static targets = ['tip']
+
+ connect () {
+ this.cfg = this.configValue
+ this.svg = this.element.querySelector('svg')
+ if (!this.svg || !this.cfg || !this.cfg.series) return
+ const ns = 'http://www.w3.org/2000/svg'
+ this.cross = document.createElementNS(ns, 'line')
+ this.cross.setAttribute('stroke', '#94a3b8')
+ this.cross.setAttribute('stroke-width', '1')
+ this.cross.setAttribute('stroke-dasharray', '3 3')
+ this.cross.setAttribute('y1', this.cfg.plotTop)
+ this.cross.setAttribute('y2', this.cfg.plotBottom)
+ this.cross.style.opacity = '0'
+ this.cross.style.pointerEvents = 'none'
+ this.svg.appendChild(this.cross)
+ this.dots = this.cfg.series.map((s) => {
+ const dot = document.createElementNS(ns, 'circle')
+ dot.setAttribute('r', '4.5')
+ dot.setAttribute('fill', s.color)
+ dot.setAttribute('stroke', '#fff')
+ dot.setAttribute('stroke-width', '2')
+ dot.style.opacity = '0'
+ dot.style.pointerEvents = 'none'
+ this.svg.appendChild(dot)
+ return dot
+ })
+ this.onMove = this.onMove.bind(this)
+ this.onLeave = this.onLeave.bind(this)
+ this.svg.addEventListener('pointermove', this.onMove)
+ this.svg.addEventListener('pointerleave', this.onLeave)
+ }
+
+ disconnect () {
+ if (!this.svg) return
+ this.svg.removeEventListener('pointermove', this.onMove)
+ this.svg.removeEventListener('pointerleave', this.onLeave)
+ }
+
+ onMove (event) {
+ const point = this.svg.createSVGPoint()
+ point.x = event.clientX
+ point.y = event.clientY
+ const x = point.matrixTransform(this.svg.getScreenCTM().inverse()).x
+ let index = 0
+ let best = Infinity
+ this.cfg.xs.forEach((xv, idx) => {
+ const distance = Math.abs(xv - x)
+ if (distance < best) {
+ best = distance
+ index = idx
+ }
+ })
+ const cx = this.cfg.xs[index]
+ this.cross.setAttribute('x1', cx)
+ this.cross.setAttribute('x2', cx)
+ this.cross.style.opacity = '1'
+ this.cfg.series.forEach((s, si) => {
+ this.dots[si].setAttribute('cx', cx)
+ this.dots[si].setAttribute('cy', s.ys[index])
+ this.dots[si].style.opacity = '1'
+ })
+ if (!this.hasTipTarget) return
+ const tip = this.tipTarget
+ tip.replaceChildren()
+ const month = document.createElement('div')
+ month.className = 'mb-1 text-[11px] font-bold text-slate-300'
+ month.textContent = this.cfg.labels[index]
+ tip.appendChild(month)
+ this.cfg.series.forEach((s) => {
+ const row = document.createElement('div')
+ row.className = 'flex items-center gap-1.5 leading-relaxed'
+ const key = document.createElement('span')
+ key.className = 'h-0.5 w-3.5 flex-none rounded'
+ key.style.background = s.color
+ const value = document.createElement('span')
+ value.className = 'font-bold tabular-nums'
+ value.textContent = s.values[index]
+ const name = document.createElement('span')
+ name.className = 'text-slate-300'
+ name.textContent = s.name
+ row.append(key, value, name)
+ tip.appendChild(row)
+ })
+ tip.style.opacity = '1'
+ const rect = this.element.getBoundingClientRect()
+ let left = event.clientX - rect.left + 14
+ if (left + tip.offsetWidth > rect.width) {
+ left = event.clientX - rect.left - tip.offsetWidth - 14
+ }
+ tip.style.left = `${left}px`
+ tip.style.top = `${event.clientY - rect.top + 14}px`
+ }
+
+ onLeave () {
+ this.cross.style.opacity = '0'
+ this.dots.forEach((dot) => { dot.style.opacity = '0' })
+ if (this.hasTipTarget) this.tipTarget.style.opacity = '0'
+ }
+}
diff --git a/app/javascript/controllers/contact_topics_controller.js b/app/javascript/controllers/contact_topics_controller.js
new file mode 100644
index 0000000000..3f5ff322db
--- /dev/null
+++ b/app/javascript/controllers/contact_topics_controller.js
@@ -0,0 +1,84 @@
+import { Controller } from '@hotwired/stimulus'
+
+// Contact-topic checklist for the case-contact form (details). Every org contact topic is
+// listed; checking one reveals its notes field and CREATES the ContactTopicAnswer right away
+// (POST /contact_topic_answers, storing the new id) so the 2s autosave then only UPDATES it --
+// this is what keeps autosave from writing duplicate answers. Unchecking destroys the answer.
+// Unchecked topics keep their fields `disabled` so they never submit an empty answer.
+//
+// Connects to data-controller="contact-topics"
+export default class extends Controller {
+ static values = { route: String, caseContactId: Number }
+ static targets = ['dialog']
+
+ connect () {
+ this.headers = { 'Content-Type': 'application/json', Accept: 'application/json' }
+ const token = document.querySelector('meta[name="csrf-token"]')
+ if (token) { this.headers['X-CSRF-Token'] = token.content } // absent in the test env
+ }
+
+ toggle (e) {
+ const group = e.target.closest('[data-topic-group]')
+ const notes = group.querySelector('[data-topic-notes]')
+ const fields = notes.querySelectorAll('input, textarea')
+ const idField = notes.querySelector('input[name*="[id]"]')
+ const textarea = notes.querySelector('textarea')
+
+ if (e.target.checked) {
+ fields.forEach(field => { field.disabled = false })
+ notes.classList.remove('hidden')
+ if (!idField.value) { this.create(group, idField) }
+ textarea.focus()
+ } else if (textarea.value.trim() && this.hasDialogTarget) {
+ // Has notes: confirm through the design-system