From 003960ed7975706d0cc9fb098c023c5c910df5e3 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 28 Feb 2026 17:31:04 -0500 Subject: [PATCH 1/6] Dynamic facilitator border and inactive button graying on affiliation rows Co-Authored-By: Claude Opus 4.6 --- .../controllers/inactive_toggle_controller.js | 34 +++++++++++++++++-- .../_affiliation_fields.html.erb | 2 +- app/views/people/_affiliation_fields.html.erb | 2 +- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/app/frontend/javascript/controllers/inactive_toggle_controller.js b/app/frontend/javascript/controllers/inactive_toggle_controller.js index 16d5e34ae..7dbe60695 100644 --- a/app/frontend/javascript/controllers/inactive_toggle_controller.js +++ b/app/frontend/javascript/controllers/inactive_toggle_controller.js @@ -1,17 +1,43 @@ import { Controller } from "@hotwired/stimulus"; +const COLORS = "sky|emerald|indigo|purple|teal|violet|orange|rose|blue|pink|cyan|lime|yellow|fuchsia|amber|green|slate|red"; +const COLOR_RE = new RegExp(`\\b(hover:)?(bg|text|border)-(${COLORS})-\\d+\\b`, "g"); + +function grayOut(el) { + el.className = el.className.replace(COLOR_RE, (_, hover, prop) => { + if (hover) return "hover:bg-gray-200"; + if (prop === "bg") return "bg-gray-100"; + if (prop === "text") return "text-gray-400"; + if (prop === "border") return "border-gray-300"; + return _; + }); +} + export default class extends Controller { connect() { this.endDateInput = this.element.querySelector("input[type='date'][name*='end_date']"); - if (this.endDateInput) { - this.apply(); - } + this.titleInput = this.element.querySelector("textarea[name*='title']"); + + // Save original classes for profile buttons and their styled children + this._savedClasses = []; + this.element.querySelectorAll("a.group, a.group span").forEach((el) => { + this._savedClasses.push({ el, className: el.className }); + }); + + if (this.endDateInput) this.apply(); + if (this.titleInput) this.updateBorder(); } toggle() { this.apply(); } + updateBorder() { + if (!this.titleInput) return; + const isFacilitator = this.titleInput.value.toLowerCase().includes("facilitator"); + this.element.style.borderLeft = `4px solid ${isFacilitator ? "#e879f9" : "#d1d5db"}`; + } + apply() { if (!this.endDateInput) return; const value = this.endDateInput.value; @@ -20,9 +46,11 @@ export default class extends Controller { if (isPast) { this.element.classList.add("bg-gray-100", "border-gray-300", "opacity-60"); this.element.classList.remove("bg-white", "border-gray-200"); + this.element.querySelectorAll("a.group, a.group span").forEach((el) => grayOut(el)); } else { this.element.classList.remove("bg-gray-100", "border-gray-300", "opacity-60"); this.element.classList.add("bg-white", "border-gray-200"); + this._savedClasses.forEach(({ el, className }) => { el.className = className; }); } } } diff --git a/app/views/organizations/_affiliation_fields.html.erb b/app/views/organizations/_affiliation_fields.html.erb index b159e57fd..dcb00080a 100644 --- a/app/views/organizations/_affiliation_fields.html.erb +++ b/app/views/organizations/_affiliation_fields.html.erb @@ -32,7 +32,7 @@ rows: 1, value: f.object&.title || f.object&.position || "Facilitator", style: "height: 42px; min-height: 42px;", - data: { action: "input->affiliation-dates#recalculate" } + data: { action: "input->affiliation-dates#recalculate input->inactive-toggle#updateBorder" } } %> diff --git a/app/views/people/_affiliation_fields.html.erb b/app/views/people/_affiliation_fields.html.erb index 545b5abb3..b63801bea 100644 --- a/app/views/people/_affiliation_fields.html.erb +++ b/app/views/people/_affiliation_fields.html.erb @@ -33,7 +33,7 @@ rows: 1, value: f.object.title || f.object.position || "Facilitator", style: "height: 42px; min-height: 42px;", - data: { action: "input->affiliation-dates#recalculate" } + data: { action: "input->affiliation-dates#recalculate input->inactive-toggle#updateBorder" } } %> From df9a39d087b43345c481248789f006b0bc34943f Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 8 Mar 2026 06:55:35 -0400 Subject: [PATCH 2/6] Narrow title field and rename Primary to Primary contact on affiliation rows Co-Authored-By: Claude Opus 4.6 --- app/views/organizations/_affiliation_fields.html.erb | 4 ++-- app/views/people/_affiliation_fields.html.erb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/organizations/_affiliation_fields.html.erb b/app/views/organizations/_affiliation_fields.html.erb index dcb00080a..fcf915ad9 100644 --- a/app/views/organizations/_affiliation_fields.html.erb +++ b/app/views/organizations/_affiliation_fields.html.erb @@ -25,7 +25,7 @@ <% end %> -
+
<%= f.input :title, as: :text, input_html: { @@ -61,7 +61,7 @@
- + <%= f.check_box :primary_contact, checked: f.object.primary_contact? || !f.object.persisted?, class: "h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" %> diff --git a/app/views/people/_affiliation_fields.html.erb b/app/views/people/_affiliation_fields.html.erb index b63801bea..027c3e106 100644 --- a/app/views/people/_affiliation_fields.html.erb +++ b/app/views/people/_affiliation_fields.html.erb @@ -25,7 +25,7 @@ <% end %>
-
+
<%= f.input :title, as: :text, @@ -63,7 +63,7 @@
- + <%= f.check_box :primary_contact, checked: f.object.primary_contact? || !f.object.persisted?, class: "h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" %> From d3bc595368cc5d1adf0a9c67b8a27c1af8381464 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 8 Mar 2026 07:10:41 -0400 Subject: [PATCH 3/6] Refactor inactive_toggle_controller to use Stimulus targets instead of DOM queries Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + .../controllers/inactive_toggle_controller.js | 37 ++--- .../_affiliation_fields.html.erb | 140 +++++++++--------- app/views/people/_affiliation_fields.html.erb | 140 +++++++++--------- 4 files changed, 169 insertions(+), 149 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a6a282d5c..3aca53cd7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,6 +153,7 @@ bundle exec bundle-audit check --update - Controller naming: `[name]_controller.js` - Keep controllers focused and small - Use Tailwind CSS v4 utility classes +- **Use Stimulus targets and data attributes** to reference DOM elements — avoid `this.element.querySelector` and direct DOM queries. Declare `static targets = [...]` and use `data-[controller]-target` attributes in views. ## Migrations diff --git a/app/frontend/javascript/controllers/inactive_toggle_controller.js b/app/frontend/javascript/controllers/inactive_toggle_controller.js index 7dbe60695..ae502e8b8 100644 --- a/app/frontend/javascript/controllers/inactive_toggle_controller.js +++ b/app/frontend/javascript/controllers/inactive_toggle_controller.js @@ -14,18 +14,19 @@ function grayOut(el) { } export default class extends Controller { - connect() { - this.endDateInput = this.element.querySelector("input[type='date'][name*='end_date']"); - this.titleInput = this.element.querySelector("textarea[name*='title']"); + static targets = ["endDate", "title", "row", "button"] + connect() { // Save original classes for profile buttons and their styled children this._savedClasses = []; - this.element.querySelectorAll("a.group, a.group span").forEach((el) => { - this._savedClasses.push({ el, className: el.className }); + this.buttonTargets.forEach((btn) => { + btn.querySelectorAll("a.group, a.group span").forEach((el) => { + this._savedClasses.push({ el, className: el.className }); + }); }); - if (this.endDateInput) this.apply(); - if (this.titleInput) this.updateBorder(); + if (this.hasEndDateTarget) this.apply(); + if (this.hasTitleTarget) this.updateBorder(); } toggle() { @@ -33,23 +34,25 @@ export default class extends Controller { } updateBorder() { - if (!this.titleInput) return; - const isFacilitator = this.titleInput.value.toLowerCase().includes("facilitator"); - this.element.style.borderLeft = `4px solid ${isFacilitator ? "#e879f9" : "#d1d5db"}`; + if (!this.hasTitleTarget) return; + const isFacilitator = this.titleTarget.value.toLowerCase().includes("facilitator"); + this.rowTarget.style.borderLeft = `4px solid ${isFacilitator ? "#e879f9" : "#d1d5db"}`; } apply() { - if (!this.endDateInput) return; - const value = this.endDateInput.value; + if (!this.hasEndDateTarget) return; + const value = this.endDateTarget.value; const isPast = value && new Date(value) < new Date(new Date().toDateString()); if (isPast) { - this.element.classList.add("bg-gray-100", "border-gray-300", "opacity-60"); - this.element.classList.remove("bg-white", "border-gray-200"); - this.element.querySelectorAll("a.group, a.group span").forEach((el) => grayOut(el)); + this.rowTarget.classList.add("bg-gray-100", "border-gray-300", "opacity-60"); + this.rowTarget.classList.remove("bg-white", "border-gray-200"); + this.buttonTargets.forEach((btn) => { + btn.querySelectorAll("a.group, a.group span").forEach((el) => grayOut(el)); + }); } else { - this.element.classList.remove("bg-gray-100", "border-gray-300", "opacity-60"); - this.element.classList.add("bg-white", "border-gray-200"); + this.rowTarget.classList.remove("bg-gray-100", "border-gray-300", "opacity-60"); + this.rowTarget.classList.add("bg-white", "border-gray-200"); this._savedClasses.forEach(({ el, className }) => { el.className = className; }); } } diff --git a/app/views/organizations/_affiliation_fields.html.erb b/app/views/organizations/_affiliation_fields.html.erb index fcf915ad9..c2106f988 100644 --- a/app/views/organizations/_affiliation_fields.html.erb +++ b/app/views/organizations/_affiliation_fields.html.erb @@ -1,76 +1,84 @@ <% if allowed_to?(:manage?, Organization) %> <% expired = f.object.inactive? || (f.object.end_date.present? && f.object.end_date < Date.current) %> -
id="<%= dom_id(f.object) %>"<% end %> - data-controller="inactive-toggle"> -
- <% if f.object.persisted? && f.object.person.present? %> - - <% show_email = f.object.person.profile_show_email? || allowed_to?(:manage?, Person) %> - <%= person_profile_button(f.object.person, truncate_at: 30, subtitle: (f.object.person.preferred_email if show_email)) %> - <%= f.hidden_field :person_id %> - <% else %> - <%= f.input :person_id, - include_blank: true, - required: true, - input_html: { - data: { - controller: "remote-select", - remote_select_model_value: "person" - } - }, - label: "Person" - %> - <% end %> -
+
+
id="<%= dom_id(f.object) %>"<% end %> + data-inactive-toggle-target="row"> +
+ <% if f.object.persisted? && f.object.person.present? %> + + <% show_email = f.object.person.profile_show_email? || allowed_to?(:manage?, Person) %> + <%= person_profile_button(f.object.person, truncate_at: 30, subtitle: (f.object.person.preferred_email if show_email)) %> + <%= f.hidden_field :person_id %> + <% else %> + <%= f.input :person_id, + include_blank: true, + required: true, + input_html: { + data: { + controller: "remote-select", + remote_select_model_value: "person" + } + }, + label: "Person" + %> + <% end %> +
-
- <%= f.input :title, - as: :text, - input_html: { - rows: 1, - value: f.object&.title || f.object&.position || "Facilitator", - style: "height: 42px; min-height: 42px;", - data: { action: "input->affiliation-dates#recalculate input->inactive-toggle#updateBorder" } - } %> -
+
+ <%= f.input :title, + as: :text, + input_html: { + rows: 1, + value: f.object&.title || f.object&.position || "Facilitator", + style: "height: 42px; min-height: 42px;", + data: { + inactive_toggle_target: "title", + action: "input->affiliation-dates#recalculate input->inactive-toggle#updateBorder" + } + } %> +
-
- <%= f.input :start_date, - as: :string, - label: "Start", - input_html: { - type: "date", - value: (f.object.start_date || (Date.current unless f.object.persisted?))&.strftime("%Y-%m-%d"), - class: "rounded-md border-gray-300 focus:ring-blue-500 focus:border-blue-500 text-sm", - data: { action: "change->affiliation-dates#recalculate" } - } %> -
+
+ <%= f.input :start_date, + as: :string, + label: "Start", + input_html: { + type: "date", + value: (f.object.start_date || (Date.current unless f.object.persisted?))&.strftime("%Y-%m-%d"), + class: "rounded-md border-gray-300 focus:ring-blue-500 focus:border-blue-500 text-sm", + data: { action: "change->affiliation-dates#recalculate" } + } %> +
-
- <%= f.input :end_date, - as: :string, - label: "End", - input_html: { - type: "date", - value: f.object.end_date&.strftime("%Y-%m-%d"), - class: "rounded-md border-gray-300 focus:ring-blue-500 focus:border-blue-500 text-sm", - data: { action: "change->inactive-toggle#toggle change->affiliation-dates#recalculate" } - } %> -
+
+ <%= f.input :end_date, + as: :string, + label: "End", + input_html: { + type: "date", + value: f.object.end_date&.strftime("%Y-%m-%d"), + class: "rounded-md border-gray-300 focus:ring-blue-500 focus:border-blue-500 text-sm", + data: { + inactive_toggle_target: "endDate", + action: "change->inactive-toggle#toggle change->affiliation-dates#recalculate" + } + } %> +
-
- - <%= f.check_box :primary_contact, - checked: f.object.primary_contact? || !f.object.persisted?, - class: "h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" %> -
+
+ + <%= f.check_box :primary_contact, + checked: f.object.primary_contact? || !f.object.persisted?, + class: "h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" %> +
-
- <%= link_to_remove_association "Remove", - f, - class: "text-sm text-gray-400 hover:text-red-600 underline whitespace-nowrap admin-only bg-blue-100 rounded px-2 py-1" %> +
+ <%= link_to_remove_association "Remove", + f, + class: "text-sm text-gray-400 hover:text-red-600 underline whitespace-nowrap admin-only bg-blue-100 rounded px-2 py-1" %> +
<% else %> diff --git a/app/views/people/_affiliation_fields.html.erb b/app/views/people/_affiliation_fields.html.erb index 027c3e106..d8b0586fe 100644 --- a/app/views/people/_affiliation_fields.html.erb +++ b/app/views/people/_affiliation_fields.html.erb @@ -1,78 +1,86 @@ <% if allowed_to?(:manage?, Person) %> <% expired = f.object.inactive? || (f.object.end_date.present? && f.object.end_date < Date.current) %> -
-
- <% if f.object.persisted? && f.object.organization.present? %> - - <%= organization_profile_button(f.object.organization, truncate_at: 30) %> - <%= f.hidden_field :organization_id %> - <% else %> - <%= f.input :organization_id, - include_blank: true, - required: true, - input_html: { - data: { - controller: "remote-select", - remote_select_model_value: "organization" - } - }, - error: "Organization can't be blank", - prompt: "Search by name", - label: "Organization", - label_html: { class: "block text-sm font-medium text-gray-700 mb-1 " } %> - <% end %> -
+
+
+
+ <% if f.object.persisted? && f.object.organization.present? %> + + <%= organization_profile_button(f.object.organization, truncate_at: 30) %> + <%= f.hidden_field :organization_id %> + <% else %> + <%= f.input :organization_id, + include_blank: true, + required: true, + input_html: { + data: { + controller: "remote-select", + remote_select_model_value: "organization" + } + }, + error: "Organization can't be blank", + prompt: "Search by name", + label: "Organization", + label_html: { class: "block text-sm font-medium text-gray-700 mb-1 " } %> + <% end %> +
+ +
+
+ <%= f.input :title, + as: :text, + input_html: { + rows: 1, + value: f.object.title || f.object.position || "Facilitator", + style: "height: 42px; min-height: 42px;", + data: { + inactive_toggle_target: "title", + action: "input->affiliation-dates#recalculate input->inactive-toggle#updateBorder" + } + } %> +
+
-
-
- <%= f.input :title, - as: :text, +
+ <%= f.input :start_date, + as: :string, + label: "Start", input_html: { - rows: 1, - value: f.object.title || f.object.position || "Facilitator", - style: "height: 42px; min-height: 42px;", - data: { action: "input->affiliation-dates#recalculate input->inactive-toggle#updateBorder" } + type: "date", + value: (f.object.start_date || (Date.current unless f.object.persisted?))&.strftime("%Y-%m-%d"), + class: "rounded-md border-gray-300 focus:ring-blue-500 focus:border-blue-500 text-sm", + data: { action: "change->affiliation-dates#recalculate" } } %>
-
- -
- <%= f.input :start_date, - as: :string, - label: "Start", - input_html: { - type: "date", - value: (f.object.start_date || (Date.current unless f.object.persisted?))&.strftime("%Y-%m-%d"), - class: "rounded-md border-gray-300 focus:ring-blue-500 focus:border-blue-500 text-sm", - data: { action: "change->affiliation-dates#recalculate" } - } %> -
-
- <%= f.input :end_date, - as: :string, - label: "End", - input_html: { - type: "date", - value: f.object.end_date&.strftime("%Y-%m-%d"), - class: "rounded-md border-gray-300 focus:ring-blue-500 focus:border-blue-500 text-sm", - data: { action: "change->inactive-toggle#toggle change->affiliation-dates#recalculate" } - } %> -
+
+ <%= f.input :end_date, + as: :string, + label: "End", + input_html: { + type: "date", + value: f.object.end_date&.strftime("%Y-%m-%d"), + class: "rounded-md border-gray-300 focus:ring-blue-500 focus:border-blue-500 text-sm", + data: { + inactive_toggle_target: "endDate", + action: "change->inactive-toggle#toggle change->affiliation-dates#recalculate" + } + } %> +
-
- - <%= f.check_box :primary_contact, - checked: f.object.primary_contact? || !f.object.persisted?, - class: "h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" %> -
+
+ + <%= f.check_box :primary_contact, + checked: f.object.primary_contact? || !f.object.persisted?, + class: "h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" %> +
-
- <%= link_to_remove_association "Remove", - f, - class: "text-sm text-gray-400 hover:text-red-600 underline whitespace-nowrap admin-only bg-blue-100 rounded px-2 py-1" %> +
+ <%= link_to_remove_association "Remove", + f, + class: "text-sm text-gray-400 hover:text-red-600 underline whitespace-nowrap admin-only bg-blue-100 rounded px-2 py-1" %> +
<% else %> From 3a17516d9ee48d85263f9be9ca8ba1696de4e938 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 8 Mar 2026 07:15:12 -0400 Subject: [PATCH 4/6] Document Stimulus shorthand action descriptors convention Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 3aca53cd7..f3ac1e1c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,6 +154,7 @@ bundle exec bundle-audit check --update - Keep controllers focused and small - Use Tailwind CSS v4 utility classes - **Use Stimulus targets and data attributes** to reference DOM elements — avoid `this.element.querySelector` and direct DOM queries. Declare `static targets = [...]` and use `data-[controller]-target` attributes in views. +- **Use Stimulus shorthand action descriptors and shorthand pairs** — omit the event when it's the default for that element (e.g., `input` for ``/`