diff --git a/app/controllers/affiliations_controller.rb b/app/controllers/affiliations_controller.rb index 3fd157504..da1278bf2 100644 --- a/app/controllers/affiliations_controller.rb +++ b/app/controllers/affiliations_controller.rb @@ -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 @@ -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 diff --git a/app/decorators/comment_decorator.rb b/app/decorators/comment_decorator.rb index 550309cc1..3575f4a37 100644 --- a/app/decorators/comment_decorator.rb +++ b/app/decorators/comment_decorator.rb @@ -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 @@ -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 diff --git a/app/frontend/javascript/controllers/address_select_controller.js b/app/frontend/javascript/controllers/address_select_controller.js index d93964305..f1de3fb4b 100644 --- a/app/frontend/javascript/controllers/address_select_controller.js +++ b/app/frontend/javascript/controllers/address_select_controller.js @@ -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 { @@ -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(); } diff --git a/app/frontend/javascript/controllers/affiliation_dates_controller.js b/app/frontend/javascript/controllers/affiliation_dates_controller.js index 278c8018d..8bf2371ab 100644 --- a/app/frontend/javascript/controllers/affiliation_dates_controller.js +++ b/app/frontend/javascript/controllers/affiliation_dates_controller.js @@ -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) @@ -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 || "" })) } diff --git a/app/frontend/javascript/controllers/inactive_toggle_controller.js b/app/frontend/javascript/controllers/inactive_toggle_controller.js index 885be7a4b..5dad1705e 100644 --- a/app/frontend/javascript/controllers/inactive_toggle_controller.js +++ b/app/frontend/javascript/controllers/inactive_toggle_controller.js @@ -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() { @@ -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"; } diff --git a/app/frontend/javascript/controllers/paginated_fields_controller.js b/app/frontend/javascript/controllers/paginated_fields_controller.js index abbb1667a..0a9606f1b 100644 --- a/app/frontend/javascript/controllers/paginated_fields_controller.js +++ b/app/frontend/javascript/controllers/paginated_fields_controller.js @@ -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() { diff --git a/app/frontend/stylesheets/application.tailwind.css b/app/frontend/stylesheets/application.tailwind.css index 16eb622ca..bcf1b2fe4 100644 --- a/app/frontend/stylesheets/application.tailwind.css +++ b/app/frontend/stylesheets/application.tailwind.css @@ -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 diff --git a/app/helpers/comments_helper.rb b/app/helpers/comments_helper.rb index 7c5e5406c..691007390 100644 --- a/app/helpers/comments_helper.rb +++ b/app/helpers/comments_helper.rb @@ -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 diff --git a/app/helpers/organization_helper.rb b/app/helpers/organization_helper.rb index 20769ee63..aced6519f 100644 --- a/app/helpers/organization_helper.rb +++ b/app/helpers/organization_helper.rb @@ -1,16 +1,48 @@ module OrganizationHelper - def organization_profile_button(organization, truncate_at: nil, subtitle: nil, label: nil, data: {}, inactive: false) + def organization_profile_button(organization, truncate_at: nil, subtitle: nil, label: nil, data: {}, inactive: false, tint: nil, compact: false) + # Compact mode shrinks the control to roughly a text input's height, for use + # inline beside form fields (e.g. the affiliation editor rows). + padding = compact ? "px-3 py-1" : "px-4 py-2" + avatar_size = compact ? "w-8 h-8" : "w-10 h-10" + initial_text_size = compact ? "text-sm" : "text-lg" + if inactive bg = "bg-gray-100" hover_bg = "hover:bg-gray-200" text = "text-gray-400" border = "border-gray-300" + elsif tint == :facilitator + bg = "bg-purple-100" + hover_bg = "hover:bg-purple-200" + text = "text-gray-800" + border = "border-purple-300" + elsif tint == :facilitator_light + bg = "bg-purple-50" + hover_bg = "hover:bg-purple-100" + text = "text-gray-800" + border = "border-purple-200" + elsif tint == :nonfac + bg = "bg-blue-100" + hover_bg = "hover:bg-blue-200" + text = "text-gray-800" + border = "border-blue-300" + elsif tint == :nonfac_light + bg = "bg-blue-50" + hover_bg = "hover:bg-blue-100" + text = "text-gray-800" + border = "border-blue-200" + elsif tint == :muted + bg = "bg-white" + hover_bg = "hover:bg-gray-50" + text = DomainTheme.text_class_for(:organizations) + border = "border-gray-300" else bg = DomainTheme.bg_class_for(:organizations, intensity: 100) hover_bg = DomainTheme.bg_class_for(:organizations, intensity: 100, hover: true) text = DomainTheme.text_class_for(:organizations) border = DomainTheme.border_class_for(:organizations) end + shadow = tint ? "shadow-none" : "shadow-sm" hover_title = [ organization.name, subtitle ].compact_blank.join(" — ") @@ -18,19 +50,19 @@ def organization_profile_button(organization, truncate_at: nil, subtitle: nil, l data: { turbo_prefetch: false }.merge(data), title: hover_title, class: "group relative flex items-center gap-2 - w-full px-4 py-2 + w-full #{padding} border #{border} #{bg} #{hover_bg} rounded-lg transition-colors duration-200 - font-medium shadow-sm leading-none + font-medium #{shadow} leading-none overflow-hidden" do # --- Logo --- logo = if organization.respond_to?(:logo) && organization.logo.attached? image_tag organization.logo, - class: "w-10 h-10 rounded-full object-cover border border-gray-300 shadow-sm flex-shrink-0" + class: "#{avatar_size} rounded-full object-cover border border-gray-300 shadow-sm flex-shrink-0" else content_tag(:span, organization.name.first.upcase, - class: "w-10 h-10 rounded-full flex items-center justify-center - bg-emerald-200 text-emerald-700 font-bold text-lg + class: "#{avatar_size} rounded-full flex items-center justify-center + bg-emerald-200 text-emerald-700 font-bold #{initial_text_size} border border-emerald-300 shadow-sm flex-shrink-0") end diff --git a/app/helpers/person_helper.rb b/app/helpers/person_helper.rb index 9a88e2e57..2ed971de9 100644 --- a/app/helpers/person_helper.rb +++ b/app/helpers/person_helper.rb @@ -1,5 +1,5 @@ module PersonHelper - def person_profile_button(person, truncate_at: nil, subtitle: nil, display_name: nil, data: {}, inactive: false, path_params: {}, width_class: "w-full", compact: false) + def person_profile_button(person, truncate_at: nil, subtitle: nil, display_name: nil, data: {}, inactive: false, tint: nil, path_params: {}, width_class: "w-full", compact: false) # Compact mode shrinks the whole control (padding, avatar, type) for dense # tables like the registrants roster where horizontal space is at a premium. padding = compact ? "px-2 py-1" : "px-4 py-2" @@ -12,12 +12,38 @@ def person_profile_button(person, truncate_at: nil, subtitle: nil, display_name: hover_bg = "hover:bg-gray-200" text = "text-gray-400" border = "border-gray-300" + elsif tint == :facilitator + bg = "bg-purple-100" + hover_bg = "hover:bg-purple-200" + text = "text-gray-800" + border = "border-purple-300" + elsif tint == :facilitator_light + bg = "bg-purple-50" + hover_bg = "hover:bg-purple-100" + text = "text-gray-800" + border = "border-purple-200" + elsif tint == :nonfac + bg = "bg-blue-100" + hover_bg = "hover:bg-blue-200" + text = "text-gray-800" + border = "border-blue-300" + elsif tint == :nonfac_light + bg = "bg-blue-50" + hover_bg = "hover:bg-blue-100" + text = "text-gray-800" + border = "border-blue-200" + elsif tint == :muted + bg = "bg-white" + hover_bg = "hover:bg-gray-50" + text = DomainTheme.text_class_for(:people) + border = "border-gray-300" else bg = DomainTheme.bg_class_for(:people, intensity: 100) hover_bg = DomainTheme.bg_class_for(:people, intensity: 100, hover: true) text = DomainTheme.text_class_for(:people) border = DomainTheme.border_class_for(:people) end + shadow = tint ? "shadow-none" : "shadow-sm" full_name = display_name || person.try(:name) || person.to_s hover_title = [ full_name, subtitle ].compact_blank.join(" — ") @@ -35,7 +61,7 @@ def person_profile_button(person, truncate_at: nil, subtitle: nil, display_name: #{width_class} #{padding} border #{border} #{bg} #{hover_bg} rounded-lg transition-colors duration-200 - font-medium shadow-sm leading-none + font-medium #{shadow} leading-none overflow-hidden" do person = person.decorate diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index 17b3b1504..6f780a621 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -21,6 +21,9 @@ class Affiliation < ApplicationRecord # have this link. belongs_to :event_registration, optional: true, inverse_of: :affiliations + has_many :comments, -> { newest_first }, as: :commentable, dependent: :destroy + accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: proc { |attrs| attrs["body"].blank? } + # Validations validates_presence_of :organization_id validate :organization_address_belongs_to_organization @@ -75,8 +78,10 @@ class Affiliation < ApplicationRecord } before_validation :skip_if_duplicate + # Runs before validation so a reassigned org drops its stale organization_address_id + # before organization_address_belongs_to_organization would reject it. + before_validation :reset_org_scoped_links_on_org_change, on: :update before_save :set_inactive_from_dates - before_update :clear_event_registration_on_org_change after_save :sync_organization_status_with_affiliations after_save :sync_organization_affiliation_dates after_destroy :sync_organization_status_with_affiliations @@ -136,12 +141,25 @@ def skip_if_duplicate throw(:abort) if scope.exists? end - # event_registration_id records the registration that created this affiliation for - # its original org. If an admin moves the affiliation to a different org, that link - # no longer applies, so clear it — a row with no link counts as manually created, - # which reconciliation leaves alone. - def clear_event_registration_on_org_change - self.event_registration_id = nil if organization_id_changed? + # When an admin moves the affiliation to a different org (only possible from the + # standalone edit form), the links scoped to the old org no longer apply: + # - event_registration_id is cleared (a row with no link counts as manually + # created, which reconciliation leaves alone). The registration's own org + # link is separate and is updated in its org-linking step. + # - organization_address_id is re-pointed at the new org: an old-org address + # would fail organization_address_belongs_to_organization. If the new org has + # exactly one address we adopt it; otherwise it's left blank for an admin to + # set after saving. + def reset_org_scoped_links_on_org_change + return unless organization_id_changed? + + self.event_registration_id = nil + self.organization_address_id = sole_address_id_for_new_organization + end + + def sole_address_id_for_new_organization + addresses = Organization.find_by(id: organization_id)&.addresses + addresses.first.id if addresses&.one? end def set_inactive_from_dates diff --git a/app/policies/affiliation_policy.rb b/app/policies/affiliation_policy.rb index 1f99ba949..b31c44d4c 100644 --- a/app/policies/affiliation_policy.rb +++ b/app/policies/affiliation_policy.rb @@ -5,6 +5,14 @@ def destroy? record.persisted? && admin? # we don't allow users to edit their own end + def edit? + record.persisted? && admin? + end + + def update? + edit? + end + # Scoping # See https://actionpolicy.evilmartians.io/#/scoping diff --git a/app/views/affiliations/_address_picker.html.erb b/app/views/affiliations/_address_picker.html.erb index 662976ed8..99e8fe08b 100644 --- a/app/views/affiliations/_address_picker.html.erb +++ b/app/views/affiliations/_address_picker.html.erb @@ -1,6 +1,7 @@ <%# Compact numbered picker linking an affiliation to one of its organization's - addresses. The trigger button is only wide enough for a double-digit number; - the panel that opens shows each address's full one-line text. Numbers mirror + addresses. The trigger button shows the selected address's number and name, + truncated to the column width; the panel lists each address's full text. + Numbers mirror the org address editor's "Address #N" (1-based, in association order). All addresses are selectable; inactive ones are shown with an [INACTIVE] marker. When the org has no addresses, renders an equal-width spacer so the following @@ -9,30 +10,34 @@ <% options = org ? org.addresses.each_with_index.map { |address, i| [ i + 1, address ] } : [] %> <% selected_id = f.object.organization_address_id %> <% selected = options.find { |_number, address| address.id == selected_id } %> +<% inline = local_assigns.fetch(:hide_label, false) %> <% if options.any? %> -