Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
fa29a5e
Add gear on affiliation rows linking to a full affiliation editor
maebeale Aug 16, 2026
6bc9c52
Add Features & tips entry for the affiliation editor
maebeale Aug 16, 2026
aad13ee
Open the affiliation-edit gear in a new tab
maebeale Aug 17, 2026
d8df5af
Tighten affiliation-edit lookup layout and add hints
maebeale Aug 17, 2026
11e6236
Fit title/dates on one row and scroll to the row on return
maebeale Aug 17, 2026
6ffaa37
Affiliation editor: delete button, remove-on-persisted, layout polish
maebeale Aug 17, 2026
32579e0
Make affiliation card padding symmetric top and bottom
maebeale Aug 17, 2026
903f2c7
Match affiliation title font size to the date fields
maebeale Aug 17, 2026
5726034
Align primary-contact checkbox to the input row height
maebeale Aug 17, 2026
0d7fdc6
Use the text-2xs token in the affiliation editor
maebeale Aug 17, 2026
2af6c18
Route persisted-affiliation removal through the editor in system specs
maebeale Aug 17, 2026
b24e301
Surface a linked registration and address impact on the affiliation e…
maebeale Aug 17, 2026
5fd1a1c
Remove the simple_form wrapper margin below affiliation row fields
maebeale Aug 17, 2026
a762b1e
Drop the affiliation card's bottom padding (py-2 to pt-2)
maebeale Aug 17, 2026
529aabc
Affiliation editor: shared column header, grid rows, address in full …
maebeale Aug 17, 2026
d4c65e1
Affiliation editor: two-row top layout, amber org warning
maebeale Aug 17, 2026
b776a33
Affiliation editor: wrap rows below xl, fix mb-4 gap, wider address p…
maebeale Aug 17, 2026
02dd470
Affiliation rows: mobile Address+Primary share a row, Primary tooltip
maebeale Aug 17, 2026
dd6486f
Affiliation rows: Address left of Primary everywhere, fills its column
maebeale Aug 17, 2026
073ba4d
Affiliation rows: address label shows name, transparent non-facilitat…
maebeale Aug 17, 2026
983f5f4
Affiliation rows: input-grey pill for active non-facilitator rows, gr…
maebeale Aug 17, 2026
176a5f1
Affiliation rows: neutral field chrome so row color carries the meaning
maebeale Aug 17, 2026
35450ee
Affiliation rows: grey address/primary, centered title, purple facili…
maebeale Aug 17, 2026
1d4b84d
Affiliation primary contact: purple toggle switch instead of checkbox
maebeale Aug 17, 2026
514ed41
Affiliation rows: purple "Facilitator" title tint, lighter row, cente…
maebeale Aug 17, 2026
710f9b8
Affiliation rows: tint active rows by state so all four read at a glance
maebeale Aug 17, 2026
165bdf3
Fix CI: title is now an input, not a textarea; harden editor address …
maebeale Aug 17, 2026
4954a51
Affiliation title: lighter purple on inactive facilitator rows
maebeale Aug 17, 2026
fc09935
Affiliation rows: blank title = Facilitator styling; grey pill when i…
maebeale Aug 17, 2026
0eb645b
Affiliation fields: empty = transparent, filled = the pill colour
maebeale Aug 17, 2026
78c7d22
Affiliation rows: inactive facilitator fields go light purple; trust …
maebeale Aug 17, 2026
6f87eee
Affiliation rows: active near-white grey, inactive facilitator stays …
maebeale Aug 17, 2026
4d464c2
Affiliation rows: inactive facilitator uses purple-100 fills, less tr…
maebeale Aug 17, 2026
658f000
Affiliation rows: mark inactive structurally (dashed border + struck …
maebeale Aug 17, 2026
83a4286
Affiliation rows: four-colour states (purple/blue) + strike inactive …
maebeale Aug 17, 2026
c334729
Affiliation rows: strike the person/org pill name too on inactive rows
maebeale Aug 17, 2026
696e387
Affiliation rows: comment indicator next to the toggle
maebeale Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion app/controllers/affiliations_controller.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,36 @@
class AffiliationsController < ApplicationController
before_action :set_affiliation, only: %i[ destroy ]
before_action :set_affiliation, only: %i[ edit update destroy ]

def edit
authorize! @affiliation
end

def update
authorize! @affiliation
@affiliation.assign_attributes(affiliation_params)
@affiliation.comments.select(&:new_record?).each { |c| c.created_by = current_user; c.updated_by = current_user }
@affiliation.comments.select { |c| c.persisted? && c.body_changed? }.each { |c| c.updated_by = current_user }

if @affiliation.save
redirect_to affiliation_return_path, notice: "Affiliation was successfully updated.", status: :see_other
else
render :edit, status: :unprocessable_content
end
end

def destroy
authorize! @affiliation, to: :destroy?

if params[:return_to].present?
if @affiliation.destroy
redirect_to affiliation_return_path(anchor: "affiliations"),
notice: "Affiliation was removed.", status: :see_other
else
redirect_to edit_affiliation_path(@affiliation), alert: "Unable to remove affiliation."
end
return
end

affiliation = Affiliation.find(params[:id])
person = affiliation.person
destroyed = affiliation.destroy
Expand Down Expand Up @@ -33,4 +61,24 @@ def destroy
def set_affiliation
@affiliation = Affiliation.find(params[:id])
end

def affiliation_params
params.require(:affiliation).permit(
:person_id, :organization_id, :title, :start_date, :end_date, :primary_contact, :organization_address_id,
comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ]
)
end

# Return to whichever edit page the gear was clicked from, scrolled to the row
# (or the affiliations section after a delete removes the row).
def affiliation_return_path(anchor: helpers.dom_id(@affiliation))
case params[:return_to]
when "person"
edit_person_path(params[:origin_id], anchor: anchor)
when "organization"
edit_organization_path(params[:origin_id], anchor: anchor)
else
edit_affiliation_path(@affiliation)
end
end
end
2 changes: 2 additions & 0 deletions app/decorators/comment_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def source_path
when TopicSubscription then h.edit_topic_subscription_path(commentable)
when Story then h.edit_story_path(commentable)
when StoryIdea then h.edit_story_idea_path(commentable)
when Affiliation then h.edit_affiliation_path(commentable)
end
end

Expand All @@ -34,6 +35,7 @@ def source_theme
when TopicSubscription then :topic_subscriptions
when Story then :stories
when StoryIdea then :story_ideas
when Affiliation then :organizations
else :comments
end
end
Expand Down
13 changes: 7 additions & 6 deletions app/frontend/javascript/controllers/address_select_controller.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Controller } from "@hotwired/stimulus";

// Compact numbered address picker for the affiliation editor row. The trigger
// button shows only the selected address's number (or a dash); the panel lists
// each address with its full one-line text. Selecting an option writes the
// address id into a hidden field so it saves as the affiliation's
// organization_address_id.
// Numbered address picker for the affiliation editor row. The trigger button
// shows the selected address's number and name (truncated to the column width,
// or a dash when none); the panel lists each address with its full one-line
// text. Selecting an option writes the address id into a hidden field so it
// saves as the affiliation's organization_address_id.
//
// Connects to data-controller="address-select"
export default class extends Controller {
Expand Down Expand Up @@ -41,7 +41,8 @@ export default class extends Controller {
select(event) {
const option = event.currentTarget;
this.inputTarget.value = option.dataset.value;
this.labelTarget.textContent = option.dataset.number;
this.labelTarget.textContent = option.dataset.label;
this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }));
this.close();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export default class extends Controller {
if (!this.hasAffiliationsContainerTarget) return
const fields = this.affiliationsContainerTarget.querySelectorAll(".nested-fields")
fields.forEach(field => {
const inputs = field.querySelectorAll("input[name*='start_date'], input[name*='end_date'], textarea[name*='title']")
const inputs = field.querySelectorAll("input[name*='start_date'], input[name*='end_date'], input[name*='title']")
inputs.forEach(input => {
input.addEventListener("change", this.boundRecalculate)
input.addEventListener("input", this.boundRecalculate)
Expand Down Expand Up @@ -89,7 +89,7 @@ export default class extends Controller {
.map(field => ({
startDate: field.querySelector("input[name*='start_date']")?.value || "",
endDate: field.querySelector("input[name*='end_date']")?.value || "",
title: field.querySelector("textarea[name*='title']")?.value || ""
title: field.querySelector("input[name*='title']")?.value || ""
}))
}

Expand Down
121 changes: 66 additions & 55 deletions app/frontend/javascript/controllers/inactive_toggle_controller.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,16 @@
import { Controller } from "@hotwired/stimulus";

// Active themed classes used by person (sky) and organization (emerald) profile buttons
const ACTIVE_CLASSES = [
"bg-sky-50", "bg-sky-100", "bg-sky-200", "hover:bg-sky-100", "hover:bg-sky-200",
"text-sky-700", "text-sky-800", "border-sky-200", "border-sky-300",
"bg-emerald-50", "bg-emerald-100", "bg-emerald-200", "hover:bg-emerald-100", "hover:bg-emerald-200",
"text-emerald-700", "text-emerald-800", "border-emerald-200", "border-emerald-300"
];
const GRAY_CLASSES = ["bg-gray-100", "hover:bg-gray-200", "text-gray-400", "border-gray-300"];

function grayOut(el) {
ACTIVE_CLASSES.forEach((cls) => el.classList.remove(cls));
GRAY_CLASSES.forEach((cls) => el.classList.add(cls));
}

// Live styling for the affiliation editor row as you edit, before saving. Four
// states by colour: role is the hue (facilitator = purple, else blue) and status
// is the saturation (active = full, inactive = super-light). Inactive rows also
// strike their fields (.aff-ended).
export default class extends Controller {
static targets = ["endDate", "title", "row", "profileButton", "accentBar"]
static targets = ["endDate", "title", "row", "accentBar", "valueField"]
static values = { expired: Boolean }

connect() {
// Save original classes for profile buttons and their styled children
this._savedClasses = [];
this.profileButtonTargets.forEach((btn) => {
btn.querySelectorAll("a.group, a.group span").forEach((el) => {
this._savedClasses.push({ el, className: el.className });
});
});

if (this.hasEndDateTarget) this.apply();
if (this.hasTitleTarget) this.updateBorder();
else this.apply();
}

toggle() {
Expand All @@ -37,52 +20,80 @@ export default class extends Controller {
updateBorder() {
if (!this.hasTitleTarget) return;
if (this.hasAccentBarTarget) {
this.accentBarTarget.style.backgroundColor = this.isFacilitator() ? "#a855f7" : "#d1d5db";
const fac = this.isFacilitator();
const past = this.isPast();
const a = this.accentBarTarget.classList;
a.toggle("bg-purple-500", fac && !past);
a.toggle("bg-purple-300", fac && past);
a.toggle("bg-blue-500", !fac && !past);
a.toggle("bg-blue-300", !fac && past);
}
this.updateRowBackground();
this.apply();
}

apply() {
if (!this.hasEndDateTarget) return;
const isPast = this.isPast();

this.updateRowBackground();

if (isPast) {
this.profileButtonTargets.forEach((btn) => {
btn.querySelectorAll("a.group, a.group span").forEach((el) => grayOut(el));
});
} else {
this._savedClasses.forEach(({ el, className }) => { el.className = className; });
}
this.styleTitle();
this.paintFields();
this.rowTarget.classList.toggle("aff-ended", this.isPast());
}

// Single source of truth for the row tint: gray when expired, light purple for
// an active facilitator, white otherwise.
updateRowBackground() {
this.rowTarget.classList.remove(
"bg-gray-100", "border-gray-300", "opacity-60",
"bg-purple-100", "border-purple-300",
"bg-white", "border-gray-200"
styleTitle() {
if (!this.hasTitleTarget) return;
const t = this.titleTarget;
const fac = this.isFacilitator();
const past = this.isPast();
t.classList.remove(
"bg-purple-100!", "bg-purple-50!", "bg-blue-100!", "bg-blue-50!",
"text-purple-700!", "text-purple-500!", "text-blue-700!", "text-blue-500!",
"font-semibold",
"border-purple-300!", "border-purple-200!", "border-blue-300!", "border-blue-200!"
);
if (fac && !past) t.classList.add("bg-purple-100!", "text-purple-700!", "font-semibold", "border-purple-300!");
else if (fac && past) t.classList.add("bg-purple-50!", "text-purple-500!", "border-purple-200!");
else if (!fac && !past) t.classList.add("bg-blue-100!", "text-blue-700!", "font-semibold", "border-blue-300!");
else t.classList.add("bg-blue-50!", "text-blue-500!", "border-blue-200!");
}

if (this.isPast()) {
this.rowTarget.classList.add("bg-gray-100", "border-gray-300", "opacity-60");
} else if (this.isFacilitator()) {
this.rowTarget.classList.add("bg-purple-100", "border-purple-300");
} else {
this.rowTarget.classList.add("bg-white", "border-gray-200");
}
// Empty fields are transparent (row tint shows through); filled fields take the
// role+status fill colour.
paintFields() {
this.valueFieldTargets.forEach((el) => {
el.classList.remove("bg-transparent!", "bg-purple-100!", "bg-purple-50!", "bg-blue-100!", "bg-blue-50!");
el.classList.add(this.fieldHasValue(el) ? this.fillClass() : "bg-transparent!");
});
}

fillClass() {
if (this.isFacilitator()) return this.isPast() ? "bg-purple-50!" : "bg-purple-100!";
return this.isPast() ? "bg-blue-50!" : "bg-blue-100!";
}

fieldHasValue(el) {
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") return el.value.trim() !== "";
// Address button: filled when its org-address hidden input holds a value.
const hidden = el.parentElement.querySelector("input[type='hidden']");
return Boolean(hidden && hidden.value);
}

// Row background is the role hue only; status is carried by the fills/accent/title.
updateRowBackground() {
const fac = this.isFacilitator();
const r = this.rowTarget.classList;
r.remove("bg-purple-50", "border-purple-200", "bg-blue-50", "border-blue-200");
r.add(fac ? "bg-purple-50" : "bg-blue-50", fac ? "border-purple-200" : "border-blue-200");
}

// With an end date, compute from it (live); without one, the JS can't see the
// server's inactive flag, so trust the server-rendered `expired` value.
isPast() {
if (!this.hasEndDateTarget) return false;
const value = this.endDateTarget.value;
return value && new Date(value) < new Date(new Date().toDateString());
const value = this.hasEndDateTarget ? this.endDateTarget.value : "";
if (value) return new Date(value) < new Date(new Date().toDateString());
return this.expiredValue;
}

// Mirror Affiliation#facilitator? β€” an exact, case-sensitive match on
// "Facilitator" (trimmed), so the live row tint matches what the server will render.
// "Facilitator" (trimmed), so the live styling matches what the server renders.
isFacilitator() {
return this.hasTitleTarget && this.titleTarget.value.trim() === "Facilitator";
}
Expand Down
22 changes: 22 additions & 0 deletions app/frontend/javascript/controllers/paginated_fields_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,28 @@ export default class extends Controller {
this.currentPage = 1;
this.render();
this.ready = true;
this.revealHashTarget();
}

// When the page loads with a #fragment matching a row inside this controller
// (e.g. returning from the affiliation editor to its row), jump to the page
// holding that row β€” otherwise it's hidden on a later page β€” and scroll to it.
revealHashTarget() {
const hash = window.location.hash;
if (hash.length < 2) return;

const id = hash.slice(1);
const items = this.visibleItems;
const index = items.findIndex(
(el) => el.id === id || el.querySelector(`#${CSS.escape(id)}`)
);
if (index === -1) return;

this.currentPage = Math.floor(index / this.perPageValue) + 1;
this.render();

const target = document.getElementById(id) || items[index];
requestAnimationFrame(() => target.scrollIntoView({ block: "center" }));
}

get visibleItems() {
Expand Down
12 changes: 12 additions & 0 deletions app/frontend/stylesheets/application.tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,18 @@
@apply bg-gray-100;
}

/* Inactive (ended) affiliation rows strike through their field values. A native
date input's value only strikes via its inner pseudo-element (WebKit). */
.aff-ended input,
.aff-ended textarea,
.aff-ended [data-address-select-target="button"],
.aff-ended a .truncate {
text-decoration: line-through;
}
.aff-ended input::-webkit-datetime-edit {
text-decoration: line-through;
}

/* Tom Select "flat" variant: the wrapper inherits the field's bordered box,
so the inner control is transparent and borderless β€” no box-within-a-box.
Used for optional searchable-selects (e.g. the event location) so the field
Expand Down
1 change: 1 addition & 0 deletions app/helpers/comments_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ def commentable_label(record)
when TopicSubscription then "Subscription Β· #{record.topic_label}"
when Story then "Story Β· #{record.title}"
when StoryIdea then "Story idea Β· #{record.title.presence || "##{record.id}"}"
when Affiliation then "Affiliation Β· #{record.person&.full_name} @ #{record.organization&.name}"
else record.class.name.underscore.humanize
end
end
Expand Down
Loading