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? %> -
- +
+
- <%= f.hidden_field :organization_address_id, data: { address_select_target: "input" } %> - + + + <% end %> + + <% delete_confirm = @affiliation.facilitator? ? + "Removing this facilitator affiliation affects the organization's status with AWBW, which is calculated from facilitator affiliations and their start and end dates. Remove it?" : + "Remove this affiliation?" %> +
+ <%= button_to "Delete", + affiliation_path(@affiliation, return_to: params[:return_to].presence, origin_id: params[:origin_id].presence), + method: :delete, + form: { data: { turbo_confirm: delete_confirm } }, + class: "btn btn-danger-outline" %> +
+ <% if back_path %> + <%= link_to "Cancel", back_path, class: "btn btn-secondary-outline" %> + <% end %> + +
+
+ + <%= render "shared/audit_info", resource: @affiliation %> +
diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index df04cbfa1..b5d3edc59 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -338,6 +338,9 @@
<% if allowed_to?(:manage?, Organization) %>
+ <% if f.object.affiliations.present? %> + <%= render "affiliations/header", label: "Person" %> + <% end %> <%= f.fields_for :affiliations do |affiliation_form| %>
<%= render "affiliation_fields", @@ -346,7 +349,7 @@ <% end %>
-
<%= link_to_add_association "➕ Add Affiliation", +
<%= link_to_add_association "➕ Add Affiliation", f, :affiliations, class: "btn btn-secondary-outline" %>
diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index bab686594..676608aa3 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -313,6 +313,9 @@
<% if allowed_to?(:manage?, Person) %>
+ <% if f.object.affiliations.present? %> + <%= render "affiliations/header", label: "Organization" %> + <% end %> <%= f.fields_for :affiliations do |affiliation_form| %>
<%= render "affiliation_fields", @@ -321,7 +324,7 @@ <% end %>
-
<%= link_to_add_association "➕ Add Affiliation", +
<%= link_to_add_association "➕ Add Affiliation", f, :affiliations, class: "admin-only bg-blue-100 btn btn-secondary-outline" %>
diff --git a/config/features.yml b/config/features.yml index 0f7ef7dc7..91ad92c86 100644 --- a/config/features.yml +++ b/config/features.yml @@ -27,6 +27,16 @@ # ── 2026-08 ───────────────────────────────────────────────────────────────── +- name: "Edit an affiliation's details and comments" + area: people + display_status: admin_facing + released_on: 2026-08-16 + pr_number: 2235 + summary: >- + Each affiliation row on an organization or person edit page has a gear that opens + a full editor — reassign the person or organization, adjust the title and dates, + and leave comments on the affiliation. + - name: "File-upload questions on forms" area: content display_status: admin_facing diff --git a/config/routes.rb b/config/routes.rb index 906478adc..6a0cbfa1e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -270,7 +270,7 @@ resources :refunds, only: [ :new, :create, :show ] resources :organization_statuses - resources :affiliations, only: :destroy + resources :affiliations, only: [ :edit, :update, :destroy ] resources :quotes resources :monthly_reports, only: [ :index, :show ], constraints: { id: /\d+/ } diff --git a/spec/models/affiliation_spec.rb b/spec/models/affiliation_spec.rb index e4efe2540..08c5531b9 100644 --- a/spec/models/affiliation_spec.rb +++ b/spec/models/affiliation_spec.rb @@ -1,294 +1,59 @@ -require 'rails_helper' +require "rails_helper" -RSpec.describe Affiliation do - describe 'associations' do - it { should belong_to(:organization) } - it { should belong_to(:person) } - it { should belong_to(:organization_address).class_name("Address").optional } - end - - describe 'validations' do - subject do - build(:affiliation, organization: create(:organization), person: create(:person)) - end - it { should validate_presence_of(:organization_id) } - # it { should validate_presence_of(:person_id) } # we needed to not have this to support nested attrs - end - - describe '#organization_address' do - let(:organization) { create(:organization) } - let(:address) { create(:address, addressable: organization) } - - it 'is valid when the address belongs to the same organization' do - affiliation = build(:affiliation, organization: organization, organization_address: address) - expect(affiliation).to be_valid - end - - it 'is valid when no address is linked' do - affiliation = build(:affiliation, organization: organization, organization_address: nil) - expect(affiliation).to be_valid - end - - it 'is invalid when the address belongs to a different organization' do - other_address = create(:address, addressable: create(:organization)) - affiliation = build(:affiliation, organization: organization, organization_address: other_address) - expect(affiliation).not_to be_valid - expect(affiliation.errors[:organization_address_id]).to be_present - end - - it "is invalid when the address belongs to a person" do - person_address = create(:address, addressable: create(:person)) - affiliation = build(:affiliation, organization: organization, organization_address: person_address) - expect(affiliation).not_to be_valid - end - - it 'is nullified when its linked address is destroyed' do - affiliation = create(:affiliation, organization: organization, organization_address: address) - address.destroy - expect(affiliation.reload.organization_address_id).to be_nil - end - end - - describe '#active?' do - it 'is true when not inactive and has no end date' do - expect(build(:affiliation, inactive: false, end_date: nil).active?).to be true - end - - it 'is true when not inactive and the end date is in the future' do - expect(build(:affiliation, inactive: false, end_date: 1.month.from_now).active?).to be true - end - - it 'is false when flagged inactive' do - expect(build(:affiliation, inactive: true, end_date: nil).active?).to be false - end - - it 'is false when the end date has passed' do - expect(build(:affiliation, inactive: false, end_date: 1.day.ago).active?).to be false - end - end - - describe '.active' do - let!(:active_op) { create(:affiliation, inactive: false, end_date: nil) } - let!(:active_with_future_end) { create(:affiliation, inactive: false, end_date: 1.month.from_now) } - let!(:inactive_by_flag) { create(:affiliation, inactive: true, end_date: nil) } - let!(:inactive_by_end_date) { create(:affiliation, inactive: false, end_date: 1.day.ago) } - - it 'includes records with inactive: false and no end date' do - expect(described_class.active).to include(active_op) - end - - it 'includes records with inactive: false and future end date' do - expect(described_class.active).to include(active_with_future_end) - end - - it 'excludes records with inactive: true' do - expect(described_class.active).not_to include(inactive_by_flag) - end - - it 'excludes records with past end date' do - expect(described_class.active).not_to include(inactive_by_end_date) - end - - it 'qualifies end_date when joined with organizations (which also has end_date)' do - expect { - described_class.active.joins(:organization).to_a - }.not_to raise_error - end - end - - describe '#facilitator?' do - it 'is true for the exact title "Facilitator"' do - expect(build(:affiliation, title: "Facilitator").facilitator?).to be true - end - - it 'ignores surrounding whitespace' do - expect(build(:affiliation, title: " Facilitator ").facilitator?).to be true - end - - it 'is false for title variants like "Lead Facilitator"' do - expect(build(:affiliation, title: "Lead Facilitator").facilitator?).to be false - end - - it 'is case-sensitive' do - expect(build(:affiliation, title: "facilitator").facilitator?).to be false - expect(build(:affiliation, title: "FACILITATOR").facilitator?).to be false - end - - it 'is false when the title is blank' do - expect(build(:affiliation, title: nil).facilitator?).to be false - end - end - - describe '.facilitators' do - let!(:exact) { create(:affiliation, title: "Facilitator") } - let!(:whitespace) { create(:affiliation, title: " Facilitator ") } - let!(:variant) { create(:affiliation, title: "Lead Facilitator") } - let!(:lowercase) { create(:affiliation, title: "facilitator") } +RSpec.describe Affiliation, type: :model do + describe "comments" do + it "holds comments as the polymorphic commentable" do + affiliation = create(:affiliation) + comment = affiliation.comments.create!(body: "A note about this affiliation") - it 'includes only the exact, case-sensitive title "Facilitator" (whitespace-trimmed)' do - expect(described_class.facilitators).to contain_exactly(exact, whitespace) + expect(comment.commentable).to eq(affiliation) end end - describe '#sync_organization_status_with_affiliations' do - let!(:active_status) { OrganizationStatus.find_or_create_by!(name: "Active") } - let!(:inactive_status) { OrganizationStatus.find_or_create_by!(name: "Inactive") } - - it 'sets the organization to Inactive when its last active affiliation goes inactive' do - org = create(:organization, organization_status: active_status) - affiliation = create(:affiliation, organization: org, inactive: false, end_date: nil) - - affiliation.update!(inactive: true) - - expect(org.reload.organization_status).to eq(inactive_status) - end - - it 'sets an Inactive organization back to Active when it regains an active affiliation' do - org = create(:organization, organization_status: inactive_status) - - create(:affiliation, organization: org, inactive: false, end_date: nil) - - expect(org.reload.organization_status).to eq(active_status) - end - - it 'ignores non-facilitator affiliations when deciding status' do - org = create(:organization, organization_status: active_status) - create(:affiliation, organization: org, title: "Volunteer", inactive: false, end_date: nil) - - expect(org.reload.organization_status).to eq(inactive_status) - end - - %w[Pending Reinstate Unknown].each do |status_name| - it "leaves a #{status_name} organization untouched when it regains an active affiliation" do - status = OrganizationStatus.find_or_create_by!(name: status_name) - org = create(:organization, organization_status: status) + describe "lifecycle tracking" do + it "buffers an update.affiliation ahoy event when edited by a user" do + affiliation = create(:affiliation, title: "Facilitator") + Current.user = create(:user, :admin) + allow(Analytics::LifecycleBuffer).to receive(:push).and_call_original - create(:affiliation, organization: org, inactive: false, end_date: nil) + affiliation.update!(title: "Lead facilitator") - expect(org.reload.organization_status).to eq(status) - end + expect(Analytics::LifecycleBuffer).to have_received(:push) + .with(hash_including(name: "update.affiliation")) end end - describe '.active_on' do - let(:date) { Date.new(2024, 6, 1) } - let!(:spanning) { create(:affiliation, start_date: Date.new(2023, 1, 1), end_date: Date.new(2025, 1, 1)) } - let!(:open_ended) { create(:affiliation, start_date: Date.new(2023, 1, 1), end_date: nil) } - let!(:ended_before) { create(:affiliation, start_date: Date.new(2020, 1, 1), end_date: Date.new(2021, 1, 1)) } - let!(:starts_after) { create(:affiliation, start_date: Date.new(2025, 1, 1), end_date: nil) } - let!(:no_dates) { create(:affiliation, start_date: nil, end_date: nil) } - - it 'includes affiliations whose span covers the date' do - expect(described_class.active_on(date)).to include(spanning, open_ended) - end - - it 'excludes affiliations that ended before the date' do - expect(described_class.active_on(date)).not_to include(ended_before) - end + describe "reassigning the organization" do + let(:old_org) { create(:organization) } + let(:new_org) { create(:organization) } + let(:old_address) { create(:address, addressable: old_org) } - it 'excludes affiliations that start after the date' do - expect(described_class.active_on(date)).not_to include(starts_after) - end + it "drops the stale address when the new org has several addresses" do + create_list(:address, 2, addressable: new_org) + affiliation = create(:affiliation, organization: old_org, organization_address: old_address) - it 'includes affiliations with no dates on record' do - expect(described_class.active_on(date)).to include(no_dates) - end + affiliation.update!(organization: new_org) - it 'ignores the cached inactive flag, judging purely by dates' do - flagged = create(:affiliation, start_date: Date.new(2023, 1, 1), end_date: nil, inactive: true) - expect(described_class.active_on(date)).to include(flagged) + expect(affiliation.reload.organization_id).to eq(new_org.id) + expect(affiliation.organization_address_id).to be_nil end - end - describe 'status (#status_on and .with_status)' do - let!(:active_open) { create(:affiliation, start_date: Date.current.prev_year, end_date: nil) } - let!(:active_span) { create(:affiliation, start_date: Date.current.prev_year, end_date: Date.current.next_year) } - let!(:upcoming) { create(:affiliation, start_date: Date.current.next_year, end_date: nil) } - let!(:ended) { create(:affiliation, start_date: Date.current.prev_year(2), end_date: Date.current.prev_year) } - let!(:no_dates) { create(:affiliation, start_date: nil, end_date: nil) } + it "adopts the sole address of the new org" do + new_address = create(:address, addressable: new_org) + affiliation = create(:affiliation, organization: old_org, organization_address: old_address) - it 'exposes the taxonomy in display order' do - expect(Affiliation::STATUSES).to eq(%w[ Active Upcoming Inactive ]) - end - - it '#status_on classifies by flag and dates' do - expect(active_open.reload.status_on).to eq("Active") - expect(active_span.reload.status_on).to eq("Active") - expect(upcoming.reload.status_on).to eq("Upcoming") - expect(ended.reload.status_on).to eq("Inactive") - expect(no_dates.reload.status_on).to eq("Active") - end + affiliation.update!(organization: new_org) - it '.with_status returns exactly the rows whose #status_on matches (SQL ↔ Ruby agree)' do - Affiliation::STATUSES.each do |status| - expected = Affiliation.all.select { |a| a.status_on == status }.map(&:id).sort - expect(Affiliation.with_status(status).ids.sort).to eq(expected), "mismatch for #{status}" - end + expect(affiliation.reload.organization_address_id).to eq(new_address.id) end - it 'offers the combined filter option alongside the chip taxonomy' do - expect(Affiliation::FILTER_STATUSES) - .to eq([ "Active", "Upcoming", "Active & Upcoming", "Inactive" ]) - end - - it '.with_status("Active & Upcoming") returns exactly the Active and Upcoming rows' do - expected = Affiliation.all.select { |a| a.status_on.in?(%w[ Active Upcoming ]) }.map(&:id).sort - - expect(Affiliation.with_status(Affiliation::ACTIVE_OR_UPCOMING).ids.sort).to eq(expected) - expect(Affiliation.with_status(Affiliation::ACTIVE_OR_UPCOMING)).to include(active_open, active_span, upcoming, no_dates) - expect(Affiliation.with_status(Affiliation::ACTIVE_OR_UPCOMING)).not_to include(ended) - end - - it '.with_status is empty for an unknown status' do - expect(Affiliation.with_status("bogus")).to be_empty - end - end - - describe '#set_inactive_from_dates' do - let(:op) { create(:affiliation, inactive: false, end_date: nil) } - - it 'sets inactive to true when end_date is set to a past date' do - op.update!(end_date: 1.day.ago) - expect(op.reload.inactive).to be true - end - - it 'sets inactive to false when end_date is set to a future date' do - op.update!(inactive: true, end_date: 1.day.ago) - op.update!(end_date: 1.month.from_now) - expect(op.reload.inactive).to be false - end - - it 'sets inactive to false when end_date is cleared' do - op.update!(end_date: 1.day.ago) - op.update!(end_date: nil) - expect(op.reload.inactive).to be false - end - - it 'does not change inactive when unrelated fields change' do - op.update!(inactive: true) - op.update!(title: "New Title") - expect(op.reload.inactive).to be true - end - end - - describe "the registration that created the affiliation" do - it "drops the link when the affiliation is moved to a different organization" do + it "clears the event registration link" do registration = create(:event_registration) - affiliation = create(:affiliation, event_registration: registration) - other_org = create(:organization) + affiliation = create(:affiliation, organization: old_org, event_registration: registration) - affiliation.update!(organization: other_org) + affiliation.update!(organization: new_org) expect(affiliation.reload.event_registration_id).to be_nil end - - it "keeps the link when other attributes change" do - registration = create(:event_registration) - affiliation = create(:affiliation, event_registration: registration) - - affiliation.update!(title: "Lead Facilitator") - - expect(affiliation.reload.event_registration).to eq(registration) - end end end diff --git a/spec/policies/affiliation_policy_spec.rb b/spec/policies/affiliation_policy_spec.rb new file mode 100644 index 000000000..f90eaa7b4 --- /dev/null +++ b/spec/policies/affiliation_policy_spec.rb @@ -0,0 +1,34 @@ +require "rails_helper" + +RSpec.describe AffiliationPolicy, type: :policy do + let(:admin_user) { build_stubbed(:user, :admin) } + let(:regular_user) { build_stubbed(:user) } + + let(:affiliation) { build_stubbed(:affiliation) } + + def policy_for(record: nil, user:) + described_class.new(record, user: user) + end + + %i[ edit? update? destroy? ].each do |rule| + describe "##{rule}" do + context "with admin user" do + subject { policy_for(record: affiliation, user: admin_user) } + + it { is_expected.to be_allowed_to(rule) } + end + + context "with regular user" do + subject { policy_for(record: affiliation, user: regular_user) } + + it { is_expected.not_to be_allowed_to(rule) } + end + + context "with no user" do + subject { policy_for(record: affiliation, user: nil) } + + it { is_expected.not_to be_allowed_to(rule) } + end + end + end +end diff --git a/spec/requests/affiliations_spec.rb b/spec/requests/affiliations_spec.rb new file mode 100644 index 000000000..1cf65b6dc --- /dev/null +++ b/spec/requests/affiliations_spec.rb @@ -0,0 +1,122 @@ +require "rails_helper" + +RSpec.describe "/affiliations", type: :request do + let(:admin) { create(:user, :admin) } + let(:regular_user) { create(:user) } + let(:organization) { create(:organization) } + let(:person) { create(:person) } + let!(:affiliation) do + create(:affiliation, organization: organization, person: person, title: "Facilitator") + end + + describe "GET /affiliations/:id/edit" do + context "as an admin" do + before { sign_in admin } + + it "renders the edit form" do + get edit_affiliation_path(affiliation) + expect(response).to be_successful + end + + it "surfaces a linked registration with the org-linking warning" do + registration = create(:event_registration) + affiliation.update_column(:event_registration_id, registration.id) + + get edit_affiliation_path(affiliation) + + expect(response.body).to include("Linked to a registration") + expect(response.body).to include(link_organization_event_registration_path(registration)) + end + end + + context "as a non-admin" do + before { sign_in regular_user } + + it "redirects to root" do + get edit_affiliation_path(affiliation) + expect(response).to redirect_to(root_path) + end + end + end + + describe "PATCH /affiliations/:id" do + context "as an admin" do + before { sign_in admin } + + it "updates attributes and returns to the origin org edit page, scrolled to the row" do + patch affiliation_path(affiliation, return_to: "organization", origin_id: organization.id), + params: { affiliation: { title: "Lead facilitator" } } + + expect(affiliation.reload.title).to eq("Lead facilitator") + expect(response).to redirect_to(edit_organization_path(organization, anchor: "affiliation_#{affiliation.id}")) + end + + it "reassigns the person and returns to the origin person edit page" do + other_person = create(:person) + + patch affiliation_path(affiliation, return_to: "person", origin_id: person.id), + params: { affiliation: { person_id: other_person.id } } + + expect(affiliation.reload.person_id).to eq(other_person.id) + expect(response).to redirect_to(edit_person_path(person, anchor: "affiliation_#{affiliation.id}")) + end + + it "adds a comment authored by the current user" do + expect { + patch affiliation_path(affiliation, return_to: "organization", origin_id: organization.id), + params: { affiliation: { comments_attributes: [ { body: "Left a note" } ] } } + }.to change { affiliation.comments.count }.by(1) + + comment = affiliation.comments.first + expect(comment.body).to eq("Left a note") + expect(comment.created_by).to eq(admin) + end + + it "assigns the organization address through the editor" do + address = create(:address, addressable: organization) + + patch affiliation_path(affiliation, return_to: "organization", origin_id: organization.id), + params: { affiliation: { organization_address_id: address.id } } + + expect(affiliation.reload.organization_address_id).to eq(address.id) + end + end + + context "as a non-admin" do + before { sign_in regular_user } + + it "does not update and redirects to root" do + patch affiliation_path(affiliation), params: { affiliation: { title: "Changed" } } + + expect(affiliation.reload.title).to eq("Facilitator") + expect(response).to redirect_to(root_path) + end + end + end + + describe "DELETE /affiliations/:id" do + context "as an admin returning from the editor" do + before { sign_in admin } + + it "destroys the affiliation and returns to the origin edit page" do + expect { + delete affiliation_path(affiliation, return_to: "organization", origin_id: organization.id) + }.to change(Affiliation, :count).by(-1) + + expect(response).to redirect_to(edit_organization_path(organization, anchor: "affiliations")) + end + end + + context "as a non-admin" do + before { sign_in regular_user } + + it "does not destroy and redirects to root" do + expect { + delete affiliation_path(affiliation, return_to: "organization", origin_id: organization.id) + }.not_to change(Affiliation, :count) + + expect(response).to redirect_to(root_path) + end + end + end +end diff --git a/spec/requests/people_affiliation_address_spec.rb b/spec/requests/people_affiliation_address_spec.rb index 3a398534d..823091930 100644 --- a/spec/requests/people_affiliation_address_spec.rb +++ b/spec/requests/people_affiliation_address_spec.rb @@ -29,5 +29,19 @@ expect(response.body).to include("[INACTIVE]") expect(response.body).to include("123 Sesame Street") end + + it "shows a comment indicator with the latest comment for an affiliation that has comments" do + person = create(:person) + organization = create(:organization) + affiliation = create(:affiliation, person: person, organization: organization) + create(:comment, commentable: affiliation, body: "Older note", created_at: 2.days.ago) + create(:comment, commentable: affiliation, body: "Reviewed the paperwork", created_at: 1.hour.ago) + + get edit_person_path(person) + + expect(response.body).to include("fa-comment") + expect(response.body).to include("2 comments") + expect(response.body).to include("Reviewed the paperwork") + end end end diff --git a/spec/system/affiliation_dates_spec.rb b/spec/system/affiliation_dates_spec.rb index 7768afd06..b6d7feb5d 100644 --- a/spec/system/affiliation_dates_spec.rb +++ b/spec/system/affiliation_dates_spec.rb @@ -25,10 +25,10 @@ def set_date_input(input, value) ) end - def set_textarea_input(textarea, value) + def set_text_input(input, value) page.execute_script( "arguments[0].value = arguments[1]; arguments[0].dispatchEvent(new Event('input', { bubbles: true }))", - textarea, value + input, value ) end @@ -52,7 +52,7 @@ def set_textarea_input(textarea, value) # Find the Facilitator affiliation's start_date input specifically facilitator_row = all("[data-affiliation-dates-target='affiliationsContainer'] .nested-fields").find { |f| - f.find("textarea[name*='title']").value.include?("Facilitator") + f.find("input[name*='title']").value.include?("Facilitator") } start_input = facilitator_row.find("input[name*='start_date']") set_date_input(start_input, "2019-07-01") @@ -69,9 +69,9 @@ def set_textarea_input(textarea, value) # Renaming the only exact "Facilitator" to a variant drops it — facilitator # matching is exact and case-sensitive, so "Lead Facilitator" no longer counts. facilitator_row = all("[data-affiliation-dates-target='affiliationsContainer'] .nested-fields").find { |f| - f.find("textarea[name*='title']").value.strip == "Facilitator" + f.find("input[name*='title']").value.strip == "Facilitator" } - set_textarea_input(facilitator_row.find("textarea[name*='title']"), "Lead Facilitator") + set_text_input(facilitator_row.find("input[name*='title']"), "Lead Facilitator") expect(facilitator).to have_text("—", wait: 5) end @@ -109,18 +109,15 @@ def set_textarea_input(textarea, value) expect(affiliated).not_to have_text("Dec 2024") end - it "removes an affiliation and recalculates" do - visit_and_wait edit_person_path(person, admin: true) + it "removes an affiliation via the editor and recalculates" do + # Persisted affiliations are now deleted from the affiliation editor (reached + # via the row's gear); removing the Facilitator (Mar 2020) leaves Volunteer (Jun 2022). + facilitator = person.affiliations.find_by!(title: "Facilitator") + visit edit_affiliation_path(facilitator, return_to: "person", origin_id: person.id) - affiliated = find("[data-affiliation-dates-target='affiliatedSince']") - expect(affiliated).to have_text("Mar 2020") - - # Remove the Facilitator affiliation (start Mar 2020), leaving Volunteer (start Jun 2022) - facilitator_row = all("[data-affiliation-dates-target='affiliationsContainer'] .nested-fields").find { |f| - f.find("textarea[name*='title']").value.include?("Facilitator") - } - facilitator_row.find("a", text: "Remove").click + accept_confirm { click_button "Delete" } + affiliated = find("[data-affiliation-dates-target='affiliatedSince']", wait: 10) expect(affiliated).to have_text("Jun 2022", wait: 5) end end diff --git a/spec/system/organization_affiliation_dates_spec.rb b/spec/system/organization_affiliation_dates_spec.rb index 4139c27f8..7077f7caa 100644 --- a/spec/system/organization_affiliation_dates_spec.rb +++ b/spec/system/organization_affiliation_dates_spec.rb @@ -53,18 +53,15 @@ def set_date_input(input, value) end end - it "removes an affiliation and recalculates" do - visit_and_wait edit_organization_path(organization, admin: true) - - affiliated = find("[data-affiliation-dates-target='affiliatedSince']") - expect(affiliated).to have_text("May 2019") + it "removes an affiliation via the editor and recalculates" do + # Persisted affiliations are now deleted from the affiliation editor (reached + # via the row's gear); removing the Facilitator (May 2019) leaves Volunteer (Sep 2021). + facilitator = organization.affiliations.find_by!(title: "Facilitator") + visit edit_affiliation_path(facilitator, return_to: "organization", origin_id: organization.id) - # Remove the Facilitator affiliation (start May 2019), leaving Volunteer (start Sep 2021) - facilitator_row = all("[data-affiliation-dates-target='affiliationsContainer'] .nested-fields").find { |f| - f.find("textarea[name*='title']").value.include?("Facilitator") - } - facilitator_row.find("a", text: "Remove").click + accept_confirm { click_button "Delete" } + affiliated = find("[data-affiliation-dates-target='affiliatedSince']", wait: 10) expect(affiliated).to have_text("Sep 2021", wait: 5) end end diff --git a/spec/system/organization_facilitator_warning_spec.rb b/spec/system/organization_facilitator_warning_spec.rb index dbd891665..3461cbc61 100644 --- a/spec/system/organization_facilitator_warning_spec.rb +++ b/spec/system/organization_facilitator_warning_spec.rb @@ -28,7 +28,7 @@ def set_date_input(input, value) def row_for(title) all("[data-affiliation-dates-target='affiliationsContainer'] .nested-fields").find { |f| - f.find("textarea[name*='title']").value.include?(title) + f.find("input[name*='title']").value.include?(title) } end @@ -60,16 +60,15 @@ def row_for(title) expect(organization.affiliations.facilitators.first.reload.start_date).to eq(Date.new(2019, 5, 1)) end - it "warns when a facilitator affiliation is removed" do - visit_and_wait edit_organization_path(organization, admin: true) - - row_for("Facilitator").find("a", text: "Remove").click + it "warns when a facilitator affiliation is removed from the editor" do + facilitator = organization.affiliations.facilitators.first + visit edit_affiliation_path(facilitator, return_to: "organization", origin_id: organization.id) accept_confirm(/status with AWBW/) do - find("[type='submit']").click + click_button "Delete" end - expect(page).to have_current_path(organization_path(organization), wait: 10) + expect(page).to have_css("[data-affiliation-dates-ready]", wait: 10) expect(organization.affiliations.facilitators).to be_empty end diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index a9b05ba0c..40d35d901 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -199,6 +199,7 @@ "app/views/workshop_variations/new.html.erb" => "admin-only bg-blue-100", "app/views/workshops/new.html.erb" => "admin-only bg-blue-100", # edit + "app/views/affiliations/edit.html.erb" => "admin-only bg-blue-100", "app/views/banners/edit.html.erb" => "admin-only bg-blue-100", "app/views/categories/edit.html.erb" => "admin-only bg-blue-100", "app/views/category_types/edit.html.erb" => "admin-only bg-blue-100",