From 63c35a91cbbcd0fbc8ab9cba326908d1fcbe4ffb Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 22:00:14 -0400 Subject: [PATCH 01/36] Start: group FileMaker rolodex fields by meaning Co-Authored-By: Claude Opus 5.5 (1M context) From fa5b06ae2727071121e8f6942a76a63961c22649 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 22:02:58 -0400 Subject: [PATCH 02/36] Group FileMaker rolodex fields so a record reads at a glance The archive page listed ~90 rolodex fields in raw import order, so names, contact details and facilitator history were scattered among calc fields and login columns. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/concerns/fm_archive.rb | 13 +++++++++ app/models/fm_rolodex.rb | 18 ++++++++++++ app/views/fm_archives/show.html.erb | 45 ++++++++++++++++------------- spec/models/fm_rolodex_spec.rb | 38 ++++++++++++++++++++++++ spec/requests/fm_archives_spec.rb | 8 +++++ 5 files changed, 102 insertions(+), 20 deletions(-) create mode 100644 spec/models/fm_rolodex_spec.rb diff --git a/app/models/concerns/fm_archive.rb b/app/models/concerns/fm_archive.rb index 90e0f02975..2c9aa4bbe6 100644 --- a/app/models/concerns/fm_archive.rb +++ b/app/models/concerns/fm_archive.rb @@ -1,6 +1,8 @@ module FmArchive extend ActiveSupport::Concern + FIELD_GROUPS = {}.freeze + included do before_validation :set_defaults end @@ -15,6 +17,17 @@ def find_by_data(column, value) end end + # The data fields clustered under FIELD_GROUPS headings, in their listed order; fields no group names + # trail under "Other fields". Ungrouped tables come back as one unlabeled group. + def grouped_data + groups = self.class::FIELD_GROUPS + return { nil => data } if groups.empty? + + groups.transform_values { |keys| data.slice(*keys) } + .merge("Other fields" => data.except(*groups.values.flatten)) + .compact_blank + end + private def set_defaults diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index 4f1a3a9410..cd28311d31 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -101,6 +101,24 @@ class FmRolodex < ApplicationRecord "WorksiteAddrsID" => "fm_addresses" }.freeze + FIELD_GROUPS = { + "Identity & status" => %w[NamePrefix FirstName MiddleName LastName Suffix Pronouns Gender Gender2ndPerson + isAgency__c Classification Status Delete DuplicateStatus], + "Organization & role" => %w[Organization OrgID Title Department Dept2ndName PrimaryContact PrimaryContactID Mission], + "Contact" => %w[EMail EmailPrimary EmailType EmailNotes PrimaryPhoneID BestTimeToCall + PrimaryAddrsID WorksiteAddrsID WebSite], + "Notes" => %w[Comments NameNote Keywords Interest SourceOfName], + "Facilitator" => %w[FacilitatorSinceMonth FacilitatorSinceYear FacilitatorStatusPending HowTrained + TrainingMotivation LifeExperiences PrimaryAgeGroup PrimaryServiceArea OtherServiceArea + Settings BBSLicense], + "Volunteering" => %w[VolEnrollDate DropDate AvailMon AvailTue AvailWed AvailThu AvailFri AvailSat], + "Giving" => %w[DonationCount DonationTotal Amount CkNo FunderPotential CallForArtRecord], + "Personal details" => %w[DateOfBirth BirthMonth BirthDay BirthYear BirthdayFlag Anniversary Children + Ethnicity Languages], + "Mailing" => %w[MailingList AddresseeGreetingStyles ListBy IncludeTitle FamilyCode Private], + "Record keeping" => %w[ID EnteredBy DateEntered ModifiedBy DateTimeModified UserID Password] + }.freeze + HAS_MANY = { "fm_addresses" => { via: "RolodexID", label: "Addresses" }, "fm_phone_numbers" => { via: "RolodexID", label: "Phone Numbers" }, diff --git a/app/views/fm_archives/show.html.erb b/app/views/fm_archives/show.html.erb index e45d91251d..1c781d7cc2 100644 --- a/app/views/fm_archives/show.html.erb +++ b/app/views/fm_archives/show.html.erb @@ -20,27 +20,32 @@ <% end %> -
-
- <% @record.data.each do |key, value| %> -
-
<%= key %>
-
- <% link_target = @record.class::FM_LINKS[key] %> - <% if link_target && value.present? %> - <%= link_to value, fm_archive_path(value, table: link_target.sub("fm_", "")), class: "text-primary hover:underline" %> - <% elsif link_target.nil? && value.present? && @record.class.const_defined?(:CONTEXT_LINK_MAP) && (ctx = @record.data["Context"]) && (ctx_target = @record.class::CONTEXT_LINK_MAP[ctx]) %> - <%= link_to value, fm_archive_path(value, table: ctx_target.sub("fm_", "")), class: "text-primary hover:underline" %> - <% elsif link_target.nil? && value.present? && @record.class.const_defined?(:PREFIX_LINK_MAP) && (prefix_target = @record.class::PREFIX_LINK_MAP[value.to_s[0]]) %> - <%= link_to value, fm_archive_path(value, table: prefix_target.sub("fm_", "")), class: "text-primary hover:underline" %> - <% else %> - <%= value %> - <% end %> -
-
+ <% @record.grouped_data.each do |label, fields| %> +
+ <% if label %> +

<%= label %>

<% end %> -
-
+
+ <% fields.each do |key, value| %> +
+
<%= key %>
+
+ <% link_target = @record.class::FM_LINKS[key] %> + <% if link_target && value.present? %> + <%= link_to value, fm_archive_path(value, table: link_target.sub("fm_", "")), class: "text-primary hover:underline" %> + <% elsif link_target.nil? && value.present? && @record.class.const_defined?(:CONTEXT_LINK_MAP) && (ctx = @record.data["Context"]) && (ctx_target = @record.class::CONTEXT_LINK_MAP[ctx]) %> + <%= link_to value, fm_archive_path(value, table: ctx_target.sub("fm_", "")), class: "text-primary hover:underline" %> + <% elsif link_target.nil? && value.present? && @record.class.const_defined?(:PREFIX_LINK_MAP) && (prefix_target = @record.class::PREFIX_LINK_MAP[value.to_s[0]]) %> + <%= link_to value, fm_archive_path(value, table: prefix_target.sub("fm_", "")), class: "text-primary hover:underline" %> + <% else %> + <%= value %> + <% end %> +
+
+ <% end %> +
+ + <% end %> <% if @backlinks.any? %>
diff --git a/spec/models/fm_rolodex_spec.rb b/spec/models/fm_rolodex_spec.rb new file mode 100644 index 0000000000..439371d767 --- /dev/null +++ b/spec/models/fm_rolodex_spec.rb @@ -0,0 +1,38 @@ +require "rails_helper" + +RSpec.describe FmRolodex do + describe "#grouped_data" do + it "clusters fields under their group headings in the listed order" do + rolodex = described_class.new(data: { "Comments" => "Met at training", "LastName" => "Archer", "EMail" => "abe@example.com", "FirstName" => "Abe" }) + + expect(rolodex.grouped_data).to eq( + "Identity & status" => { "FirstName" => "Abe", "LastName" => "Archer" }, + "Contact" => { "EMail" => "abe@example.com" }, + "Notes" => { "Comments" => "Met at training" } + ) + expect(rolodex.grouped_data["Identity & status"].keys).to eq(%w[FirstName LastName]) + end + + it "trails fields no group names under other fields" do + rolodex = described_class.new(data: { "zcFullName" => "Abe Archer", "ID" => "00901" }) + + expect(rolodex.grouped_data).to eq( + "Record keeping" => { "ID" => "00901" }, + "Other fields" => { "zcFullName" => "Abe Archer" } + ) + end + + it "groups every documented FileMaker field exactly once" do + documented = File.read(Rails.root.join("app/models/fm_rolodex.rb")).scan(/^# (\w+)/).flatten + grouped = described_class::FIELD_GROUPS.values.flatten + + expect(grouped).to match_array(documented) + end + end + + it "leaves tables without field groups as one unlabeled group" do + organization = FmOrganization.new(data: { "OrgName" => "Shelter" }) + + expect(organization.grouped_data).to eq(nil => { "OrgName" => "Shelter" }) + end +end diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index e182b87d9b..6773088dbb 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -17,6 +17,14 @@ end end + it "shows a rolodex record's fields under group headings, most important first" do + FmRolodex.create!(fm_id: "00006", data: { "ID" => "00006", "Comments" => "Met at training", "LastName" => "Archer" }) + + get fm_archive_path("00006", table: "rolodexes") + + expect(response.body).to match(/Identity & status.*LastName.*Notes.*Comments.*Record keeping/m) + end + it "links back to the portal people carrying the archive code" do FmRolodex.create!(fm_id: "00006") person = create(:person, first_name: "Archie", last_name: "Coded", filemaker_code: "00012, 00006") From 266416b813450288c8ac8cae341e262fd8759eca Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 22:03:33 -0400 Subject: [PATCH 03/36] List grouped rolodex fields on the features page Co-Authored-By: Claude Opus 5.5 (1M context) --- config/features.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/config/features.yml b/config/features.yml index 981ec2c8c5..7c72796eaa 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4273,3 +4273,16 @@ pro_tips: - "Separate several codes with commas — a record can answer to more than one." - "Each code links to its FileMaker archive record, and that record links back to the payment or workshop." + +- name: "FileMaker rolodex records grouped by topic" + area: reporting + display_status: admin_facing + released_on: 2026-10-12 + pr_number: 2665 + action_path: "/fm_archives?table=rolodexes" + summary: >- + A FileMaker rolodex record now shows its fields in labeled sections — + identity, organization, contact, notes, facilitator history and more — with + the most useful details first. + pro_tips: + - "FileMaker's calculated and summary fields sit at the bottom under Other fields." From b4165d353e754cfc5310df21f31ff4c3ccf67f41 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 07:25:47 -0400 Subject: [PATCH 04/36] Keep the rolodex identity section to who someone is Classification and status describe someone's facilitator standing, and the delete/duplicate flags describe the record, so they read better beside those fields than beside a name. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/fm_rolodex.rb | 11 +++++------ config/features.yml | 4 ++-- spec/models/fm_rolodex_spec.rb | 4 ++-- spec/requests/fm_archives_spec.rb | 2 +- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index cd28311d31..45db892166 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -102,21 +102,20 @@ class FmRolodex < ApplicationRecord }.freeze FIELD_GROUPS = { - "Identity & status" => %w[NamePrefix FirstName MiddleName LastName Suffix Pronouns Gender Gender2ndPerson - isAgency__c Classification Status Delete DuplicateStatus], + "Identity" => %w[NamePrefix FirstName MiddleName LastName Suffix Pronouns Gender Gender2ndPerson isAgency__c], "Organization & role" => %w[Organization OrgID Title Department Dept2ndName PrimaryContact PrimaryContactID Mission], "Contact" => %w[EMail EmailPrimary EmailType EmailNotes PrimaryPhoneID BestTimeToCall PrimaryAddrsID WorksiteAddrsID WebSite], "Notes" => %w[Comments NameNote Keywords Interest SourceOfName], - "Facilitator" => %w[FacilitatorSinceMonth FacilitatorSinceYear FacilitatorStatusPending HowTrained - TrainingMotivation LifeExperiences PrimaryAgeGroup PrimaryServiceArea OtherServiceArea - Settings BBSLicense], + "Facilitator" => %w[Classification Status FacilitatorSinceMonth FacilitatorSinceYear FacilitatorStatusPending + HowTrained TrainingMotivation LifeExperiences PrimaryAgeGroup PrimaryServiceArea + OtherServiceArea Settings BBSLicense], "Volunteering" => %w[VolEnrollDate DropDate AvailMon AvailTue AvailWed AvailThu AvailFri AvailSat], "Giving" => %w[DonationCount DonationTotal Amount CkNo FunderPotential CallForArtRecord], "Personal details" => %w[DateOfBirth BirthMonth BirthDay BirthYear BirthdayFlag Anniversary Children Ethnicity Languages], "Mailing" => %w[MailingList AddresseeGreetingStyles ListBy IncludeTitle FamilyCode Private], - "Record keeping" => %w[ID EnteredBy DateEntered ModifiedBy DateTimeModified UserID Password] + "Record keeping" => %w[ID Delete DuplicateStatus EnteredBy DateEntered ModifiedBy DateTimeModified UserID Password] }.freeze HAS_MANY = { diff --git a/config/features.yml b/config/features.yml index 7c72796eaa..9098f577c4 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4282,7 +4282,7 @@ action_path: "/fm_archives?table=rolodexes" summary: >- A FileMaker rolodex record now shows its fields in labeled sections — - identity, organization, contact, notes, facilitator history and more — with - the most useful details first. + identity, organization, contact, notes, facilitator status and history, + and more — with the most useful details first. pro_tips: - "FileMaker's calculated and summary fields sit at the bottom under Other fields." diff --git a/spec/models/fm_rolodex_spec.rb b/spec/models/fm_rolodex_spec.rb index 439371d767..388208a76b 100644 --- a/spec/models/fm_rolodex_spec.rb +++ b/spec/models/fm_rolodex_spec.rb @@ -6,11 +6,11 @@ rolodex = described_class.new(data: { "Comments" => "Met at training", "LastName" => "Archer", "EMail" => "abe@example.com", "FirstName" => "Abe" }) expect(rolodex.grouped_data).to eq( - "Identity & status" => { "FirstName" => "Abe", "LastName" => "Archer" }, + "Identity" => { "FirstName" => "Abe", "LastName" => "Archer" }, "Contact" => { "EMail" => "abe@example.com" }, "Notes" => { "Comments" => "Met at training" } ) - expect(rolodex.grouped_data["Identity & status"].keys).to eq(%w[FirstName LastName]) + expect(rolodex.grouped_data["Identity"].keys).to eq(%w[FirstName LastName]) end it "trails fields no group names under other fields" do diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index 6773088dbb..fbb0077989 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -22,7 +22,7 @@ get fm_archive_path("00006", table: "rolodexes") - expect(response.body).to match(/Identity & status.*LastName.*Notes.*Comments.*Record keeping/m) + expect(response.body).to match(/Identity.*LastName.*Notes.*Comments.*Record keeping/m) end it "links back to the portal people carrying the archive code" do From 3a5374ec8d276d25a5387ccd38c7d86c23463660 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 07:27:01 -0400 Subject: [PATCH 05/36] Pair rolodex sections side by side Name beside contact and organization beside facilitator puts the details admins look up together in one glance. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/fm_rolodex.rb | 4 +-- app/views/fm_archives/show.html.erb | 52 +++++++++++++++-------------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index 45db892166..e2ee907dd3 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -103,13 +103,13 @@ class FmRolodex < ApplicationRecord FIELD_GROUPS = { "Identity" => %w[NamePrefix FirstName MiddleName LastName Suffix Pronouns Gender Gender2ndPerson isAgency__c], - "Organization & role" => %w[Organization OrgID Title Department Dept2ndName PrimaryContact PrimaryContactID Mission], "Contact" => %w[EMail EmailPrimary EmailType EmailNotes PrimaryPhoneID BestTimeToCall PrimaryAddrsID WorksiteAddrsID WebSite], - "Notes" => %w[Comments NameNote Keywords Interest SourceOfName], + "Organization & role" => %w[Organization OrgID Title Department Dept2ndName PrimaryContact PrimaryContactID Mission], "Facilitator" => %w[Classification Status FacilitatorSinceMonth FacilitatorSinceYear FacilitatorStatusPending HowTrained TrainingMotivation LifeExperiences PrimaryAgeGroup PrimaryServiceArea OtherServiceArea Settings BBSLicense], + "Notes" => %w[Comments NameNote Keywords Interest SourceOfName], "Volunteering" => %w[VolEnrollDate DropDate AvailMon AvailTue AvailWed AvailThu AvailFri AvailSat], "Giving" => %w[DonationCount DonationTotal Amount CkNo FunderPotential CallForArtRecord], "Personal details" => %w[DateOfBirth BirthMonth BirthDay BirthYear BirthdayFlag Anniversary Children diff --git a/app/views/fm_archives/show.html.erb b/app/views/fm_archives/show.html.erb index 1c781d7cc2..a1af35df10 100644 --- a/app/views/fm_archives/show.html.erb +++ b/app/views/fm_archives/show.html.erb @@ -20,32 +20,34 @@
<% end %> - <% @record.grouped_data.each do |label, fields| %> -
- <% if label %> -

<%= label %>

- <% end %> -
- <% fields.each do |key, value| %> -
-
<%= key %>
-
- <% link_target = @record.class::FM_LINKS[key] %> - <% if link_target && value.present? %> - <%= link_to value, fm_archive_path(value, table: link_target.sub("fm_", "")), class: "text-primary hover:underline" %> - <% elsif link_target.nil? && value.present? && @record.class.const_defined?(:CONTEXT_LINK_MAP) && (ctx = @record.data["Context"]) && (ctx_target = @record.class::CONTEXT_LINK_MAP[ctx]) %> - <%= link_to value, fm_archive_path(value, table: ctx_target.sub("fm_", "")), class: "text-primary hover:underline" %> - <% elsif link_target.nil? && value.present? && @record.class.const_defined?(:PREFIX_LINK_MAP) && (prefix_target = @record.class::PREFIX_LINK_MAP[value.to_s[0]]) %> - <%= link_to value, fm_archive_path(value, table: prefix_target.sub("fm_", "")), class: "text-primary hover:underline" %> - <% else %> - <%= value %> - <% end %> -
-
+
+ <% @record.grouped_data.each do |label, fields| %> +
"> + <% if label %> +

<%= label %>

<% end %> -
-
- <% end %> +
+ <% fields.each do |key, value| %> +
+
<%= key %>
+
+ <% link_target = @record.class::FM_LINKS[key] %> + <% if link_target && value.present? %> + <%= link_to value, fm_archive_path(value, table: link_target.sub("fm_", "")), class: "text-primary hover:underline" %> + <% elsif link_target.nil? && value.present? && @record.class.const_defined?(:CONTEXT_LINK_MAP) && (ctx = @record.data["Context"]) && (ctx_target = @record.class::CONTEXT_LINK_MAP[ctx]) %> + <%= link_to value, fm_archive_path(value, table: ctx_target.sub("fm_", "")), class: "text-primary hover:underline" %> + <% elsif link_target.nil? && value.present? && @record.class.const_defined?(:PREFIX_LINK_MAP) && (prefix_target = @record.class::PREFIX_LINK_MAP[value.to_s[0]]) %> + <%= link_to value, fm_archive_path(value, table: prefix_target.sub("fm_", "")), class: "text-primary hover:underline" %> + <% else %> + <%= value %> + <% end %> +
+
+ <% end %> +
+ + <% end %> + <% if @backlinks.any? %>
From 1f3003b8408e558a8a588ea3142c5043e8ffce15 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 07:29:38 -0400 Subject: [PATCH 06/36] List every phone and address on a rolodex person's contact card The rolodex row only points at the primary phone and address, so other numbers and addresses were reachable only as bare IDs in related records. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/decorators/fm_address_decorator.rb | 10 ++++++++ app/decorators/fm_phone_number_decorator.rb | 9 ++++++++ app/decorators/fm_rolodex_decorator.rb | 10 ++++++++ app/models/fm_rolodex.rb | 17 ++++++++++++++ .../fm_archives/_contact_records.html.erb | 18 +++++++++++++++ app/views/fm_archives/show.html.erb | 4 ++++ config/features.yml | 1 + spec/decorators/fm_address_decorator_spec.rb | 14 +++++++++++ .../fm_phone_number_decorator_spec.rb | 9 ++++++++ spec/decorators/fm_rolodex_decorator_spec.rb | 19 +++++++++++++++ spec/models/fm_rolodex_spec.rb | 23 +++++++++++++++++++ spec/requests/fm_archives_spec.rb | 11 +++++++++ 12 files changed, 145 insertions(+) create mode 100644 app/decorators/fm_address_decorator.rb create mode 100644 app/decorators/fm_phone_number_decorator.rb create mode 100644 app/decorators/fm_rolodex_decorator.rb create mode 100644 app/views/fm_archives/_contact_records.html.erb create mode 100644 spec/decorators/fm_address_decorator_spec.rb create mode 100644 spec/decorators/fm_phone_number_decorator_spec.rb create mode 100644 spec/decorators/fm_rolodex_decorator_spec.rb diff --git a/app/decorators/fm_address_decorator.rb b/app/decorators/fm_address_decorator.rb new file mode 100644 index 0000000000..26ef13bc6e --- /dev/null +++ b/app/decorators/fm_address_decorator.rb @@ -0,0 +1,10 @@ +class FmAddressDecorator < ApplicationDecorator + def kind + data["AddrsType"].presence || "Address" + end + + def summary + state_and_zip = data.values_at("AddrsState", "AddrsPostalCode").compact_blank.join(" ") + [ *data.values_at("AddrsLine1", "AddrsLine2", "AddrsCity"), state_and_zip, data["AddrsCountry"] ].compact_blank.join(", ") + end +end diff --git a/app/decorators/fm_phone_number_decorator.rb b/app/decorators/fm_phone_number_decorator.rb new file mode 100644 index 0000000000..2c02e29f17 --- /dev/null +++ b/app/decorators/fm_phone_number_decorator.rb @@ -0,0 +1,9 @@ +class FmPhoneNumberDecorator < ApplicationDecorator + def kind + data["PhoneType"].presence || "Phone" + end + + def summary + data.values_at("PhoneNumber", "Note").compact_blank.join(" · ") + end +end diff --git a/app/decorators/fm_rolodex_decorator.rb b/app/decorators/fm_rolodex_decorator.rb new file mode 100644 index 0000000000..fb3d3c5d8f --- /dev/null +++ b/app/decorators/fm_rolodex_decorator.rb @@ -0,0 +1,10 @@ +class FmRolodexDecorator < ApplicationDecorator + CONTACT_TAGS = { + FmPhoneNumber => { "Primary" => "PrimaryPhoneID" }, + FmAddress => { "Primary" => "PrimaryAddrsID", "Worksite" => "WorksiteAddrsID" } + }.freeze + + def contact_tags(record) + CONTACT_TAGS.fetch(record.class).select { |_, key| data[key] == record.fm_id }.keys + end +end diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index e2ee907dd3..4251fc5149 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -143,4 +143,21 @@ class FmRolodex < ApplicationRecord "fm_volunteers" => { via: "VRecID", label: "Volunteer Records" }, "fm_workshops" => { via: "AuthorID", label: "Workshops (Authored)" } }.freeze + + def phone_numbers + contact_records(FmPhoneNumber, primary_ids: data.values_at("PrimaryPhoneID")) + end + + def addresses + contact_records(FmAddress, primary_ids: data.values_at("PrimaryAddrsID", "WorksiteAddrsID")) + end + + private + + # Ones this record points at are listed first, even when their RolodexID names someone else. + def contact_records(model, primary_ids:) + primary_ids = primary_ids.compact_blank + records = model.find_by_data("RolodexID", fm_id).or(model.where(fm_id: primary_ids)).order(:fm_id).to_a + records.sort_by { |record| primary_ids.index(record.fm_id) || primary_ids.size } + end end diff --git a/app/views/fm_archives/_contact_records.html.erb b/app/views/fm_archives/_contact_records.html.erb new file mode 100644 index 0000000000..77232220b1 --- /dev/null +++ b/app/views/fm_archives/_contact_records.html.erb @@ -0,0 +1,18 @@ +<% if records.any? %> +

<%= heading %> (<%= records.size %>)

+
    + <% records.each do |record| %> +
  • + <%= record.decorate.kind %> + + <%= link_to record.decorate.summary.presence || record.fm_id, + fm_archive_path(record.fm_id, table: record.class.table_name.delete_prefix("fm_")), + class: "text-primary hover:underline" %> + <% rolodex.contact_tags(record).each do |tag| %> + <%= tag %> + <% end %> + +
  • + <% end %> +
+<% end %> diff --git a/app/views/fm_archives/show.html.erb b/app/views/fm_archives/show.html.erb index a1af35df10..83b96c33d4 100644 --- a/app/views/fm_archives/show.html.erb +++ b/app/views/fm_archives/show.html.erb @@ -45,6 +45,10 @@
<% end %> + <% if label == "Contact" && @record.is_a?(FmRolodex) %> + <%= render "contact_records", rolodex: @record.decorate, heading: "Phone numbers", records: @record.phone_numbers %> + <%= render "contact_records", rolodex: @record.decorate, heading: "Addresses", records: @record.addresses %> + <% end %> <% end %> diff --git a/config/features.yml b/config/features.yml index 9098f577c4..b2850a0019 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4286,3 +4286,4 @@ and more — with the most useful details first. pro_tips: - "FileMaker's calculated and summary fields sit at the bottom under Other fields." + - "The Contact section lists every phone number and address on file for the person, with the primary ones first and tagged." diff --git a/spec/decorators/fm_address_decorator_spec.rb b/spec/decorators/fm_address_decorator_spec.rb new file mode 100644 index 0000000000..d6308c2e0c --- /dev/null +++ b/spec/decorators/fm_address_decorator_spec.rb @@ -0,0 +1,14 @@ +require "rails_helper" + +RSpec.describe FmAddressDecorator do + it "reads an address on one line, skipping blank parts" do + address = FmAddress.new(data: { "AddrsLine1" => "88 Rose Ave", "AddrsLine2" => "", "AddrsCity" => "Venice", + "AddrsState" => "CA", "AddrsPostalCode" => "90291", "AddrsCountry" => "USA" }).decorate + + expect(address.summary).to eq("88 Rose Ave, Venice, CA 90291, USA") + end + + it "falls back to a generic kind when the type is blank" do + expect(FmAddress.new(data: { "AddrsType" => "" }).decorate.kind).to eq("Address") + end +end diff --git a/spec/decorators/fm_phone_number_decorator_spec.rb b/spec/decorators/fm_phone_number_decorator_spec.rb new file mode 100644 index 0000000000..55aae01336 --- /dev/null +++ b/spec/decorators/fm_phone_number_decorator_spec.rb @@ -0,0 +1,9 @@ +require "rails_helper" + +RSpec.describe FmPhoneNumberDecorator do + it "shows the number with its note" do + phone = FmPhoneNumber.new(data: { "PhoneType" => "Work", "PhoneNumber" => "(213) 555-0101", "Note" => "ext 4" }).decorate + + expect([ phone.kind, phone.summary ]).to eq([ "Work", "(213) 555-0101 · ext 4" ]) + end +end diff --git a/spec/decorators/fm_rolodex_decorator_spec.rb b/spec/decorators/fm_rolodex_decorator_spec.rb new file mode 100644 index 0000000000..d8a8d5a9e4 --- /dev/null +++ b/spec/decorators/fm_rolodex_decorator_spec.rb @@ -0,0 +1,19 @@ +require "rails_helper" + +RSpec.describe FmRolodexDecorator do + describe "#contact_tags" do + let(:rolodex) { FmRolodex.new(data: { "PrimaryPhoneID" => "100", "PrimaryAddrsID" => "200", "WorksiteAddrsID" => "200" }).decorate } + + it "tags the primary phone" do + expect(rolodex.contact_tags(FmPhoneNumber.new(fm_id: "100"))).to eq([ "Primary" ]) + end + + it "tags an address that is both primary and worksite" do + expect(rolodex.contact_tags(FmAddress.new(fm_id: "200"))).to eq(%w[Primary Worksite]) + end + + it "keeps phone and address IDs apart" do + expect(rolodex.contact_tags(FmPhoneNumber.new(fm_id: "200"))).to be_empty + end + end +end diff --git a/spec/models/fm_rolodex_spec.rb b/spec/models/fm_rolodex_spec.rb index 388208a76b..b485691563 100644 --- a/spec/models/fm_rolodex_spec.rb +++ b/spec/models/fm_rolodex_spec.rb @@ -30,6 +30,29 @@ end end + describe "#phone_numbers and #addresses" do + let(:rolodex) do + described_class.create!(fm_id: "00901", data: { "PrimaryPhoneID" => "PH2", "PrimaryAddrsID" => "AD3", "WorksiteAddrsID" => "AD9" }) + end + + before do + FmPhoneNumber.create!(fm_id: "PH1", data: { "RolodexID" => "00901" }) + FmPhoneNumber.create!(fm_id: "PH2", data: { "RolodexID" => "00901" }) + FmPhoneNumber.create!(fm_id: "PH5", data: { "RolodexID" => "00777" }) + FmAddress.create!(fm_id: "AD1", data: { "RolodexID" => "00901" }) + FmAddress.create!(fm_id: "AD3", data: { "RolodexID" => "00901" }) + FmAddress.create!(fm_id: "AD9", data: { "RolodexID" => "00777" }) + end + + it "lists every phone carrying this record's ID, primary first" do + expect(rolodex.phone_numbers.map(&:fm_id)).to eq(%w[PH2 PH1]) + end + + it "lists primary, then worksite, then other addresses, including a worksite filed under someone else" do + expect(rolodex.addresses.map(&:fm_id)).to eq(%w[AD3 AD9 AD1]) + end + end + it "leaves tables without field groups as one unlabeled group" do organization = FmOrganization.new(data: { "OrgName" => "Shelter" }) diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index fbb0077989..f7396c2ae2 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -25,6 +25,17 @@ expect(response.body).to match(/Identity.*LastName.*Notes.*Comments.*Record keeping/m) end + it "lists a rolodex record's phones and addresses under contact" do + FmRolodex.create!(fm_id: "00006", data: { "ID" => "00006", "EMail" => "abe@example.com", "PrimaryPhoneID" => "PH1" }) + FmPhoneNumber.create!(fm_id: "PH1", data: { "RolodexID" => "00006", "PhoneType" => "Work", "PhoneNumber" => "(213) 555-0101" }) + FmAddress.create!(fm_id: "AD1", data: { "RolodexID" => "00006", "AddrsLine1" => "88 Rose Ave", "AddrsCity" => "Venice" }) + + get fm_archive_path("00006", table: "rolodexes") + + expect(response.body).to match(/Contact.*Phone numbers \(1\).*\(213\) 555-0101.*Primary.*Addresses \(1\).*88 Rose Ave, Venice.*Record keeping/m) + expect(response.body).to include("table=phone_numbers", "table=addresses") + end + it "links back to the portal people carrying the archive code" do FmRolodex.create!(fm_id: "00006") person = create(:person, first_name: "Archie", last_name: "Coded", filemaker_code: "00012, 00006") From 47505a5b9446cf0d6b24a217a7cc8e14fc9dadfe Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 07:31:25 -0400 Subject: [PATCH 07/36] Add a field and association reference to rolodex records Admins reading an archive record couldn't tell which fields exist but are blank, or which other tables link to a person. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../fm_archives/_contact_records.html.erb | 6 +-- .../fm_archives/_field_reference.html.erb | 48 +++++++++++++++++++ app/views/fm_archives/show.html.erb | 10 ++-- config/features.yml | 1 + spec/requests/fm_archives_spec.rb | 16 +++++++ 5 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 app/views/fm_archives/_field_reference.html.erb diff --git a/app/views/fm_archives/_contact_records.html.erb b/app/views/fm_archives/_contact_records.html.erb index 77232220b1..931b2881fd 100644 --- a/app/views/fm_archives/_contact_records.html.erb +++ b/app/views/fm_archives/_contact_records.html.erb @@ -2,9 +2,9 @@

<%= heading %> (<%= records.size %>)

    <% records.each do |record| %> -
  • - <%= record.decorate.kind %> - +
  • + <%= record.decorate.kind %> + <%= link_to record.decorate.summary.presence || record.fm_id, fm_archive_path(record.fm_id, table: record.class.table_name.delete_prefix("fm_")), class: "text-primary hover:underline" %> diff --git a/app/views/fm_archives/_field_reference.html.erb b/app/views/fm_archives/_field_reference.html.erb new file mode 100644 index 0000000000..5256a48180 --- /dev/null +++ b/app/views/fm_archives/_field_reference.html.erb @@ -0,0 +1,48 @@ +
    +

    Available fields & associations

    +

    Everything a <%= model.name %> record can hold, whether or not this one has it filled in.

    +
    +
    +

    Fields

    +
    + <% model::FIELD_GROUPS.each do |label, keys| %> +
    +
    <%= label %>
    +
    + <% keys.each do |key| %> + <%= key %> + <% end %> +
    +
    + <% end %> +
    +
    +
    +
    +

    Linked from this record

    +
      + <% model::FM_LINKS.each do |key, table| %> +
    • + <%= key %> + → + <%= link_to FmArchivesController::TABLES.dig(table.delete_prefix("fm_"), :label), fm_archives_path(table: table.delete_prefix("fm_")), + class: "text-primary hover:underline" %> +
    • + <% end %> +
    +
    +
    +

    Records pointing here

    +
      + <% model::HAS_MANY.each do |table, config| %> +
    • + <%= link_to config[:label], fm_archives_path(table: table.delete_prefix("fm_")), class: "text-primary hover:underline" %> + via + <%= config[:via] %> +
    • + <% end %> +
    +
    +
    +
    +
    diff --git a/app/views/fm_archives/show.html.erb b/app/views/fm_archives/show.html.erb index 83b96c33d4..d620c40c88 100644 --- a/app/views/fm_archives/show.html.erb +++ b/app/views/fm_archives/show.html.erb @@ -28,9 +28,9 @@ <% end %>
    <% fields.each do |key, value| %> -
    -
    <%= key %>
    -
    +
    +
    <%= key %>
    +
    <% link_target = @record.class::FM_LINKS[key] %> <% if link_target && value.present? %> <%= link_to value, fm_archive_path(value, table: link_target.sub("fm_", "")), class: "text-primary hover:underline" %> @@ -75,5 +75,9 @@ <% end %>
    <% end %> + + <% if @record.class::FIELD_GROUPS.any? %> + <%= render "field_reference", model: @record.class %> + <% end %> diff --git a/config/features.yml b/config/features.yml index b2850a0019..0939572d4d 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4287,3 +4287,4 @@ pro_tips: - "FileMaker's calculated and summary fields sit at the bottom under Other fields." - "The Contact section lists every phone number and address on file for the person, with the primary ones first and tagged." + - "The blue section at the bottom lists every field a rolodex record can hold and every table that links to it, even when this record leaves them blank." diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index f7396c2ae2..6102ad1133 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -36,6 +36,22 @@ expect(response.body).to include("table=phone_numbers", "table=addresses") end + it "lists every field and association a rolodex record can hold below its cards" do + FmRolodex.create!(fm_id: "00006", data: { "ID" => "00006" }) + + get fm_archive_path("00006", table: "rolodexes") + + expect(response.body).to match(/Record keeping.*Available fields & associations.*FacilitatorSinceYear.*OrgID.*Organizations.*Workshops \(Authored\).*AuthorID/m) + end + + it "leaves the field reference off tables without field groups" do + FmProject.create!(fm_id: "P2775") + + get fm_archive_path("P2775", table: "projects") + + expect(response.body).not_to include("Available fields") + end + it "links back to the portal people carrying the archive code" do FmRolodex.create!(fm_id: "00006") person = create(:person, first_name: "Archie", last_name: "Coded", filemaker_code: "00012, 00006") From d4d8b853cccb13f843cc14428a4ce474ea4eb01f Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 07:32:05 -0400 Subject: [PATCH 08/36] Give sector and age group their own rolodex card They describe who someone serves, which is looked up on its own, not as part of their facilitator history. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/fm_rolodex.rb | 4 ++-- config/features.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index 4251fc5149..b2b08e28f5 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -107,8 +107,8 @@ class FmRolodex < ApplicationRecord PrimaryAddrsID WorksiteAddrsID WebSite], "Organization & role" => %w[Organization OrgID Title Department Dept2ndName PrimaryContact PrimaryContactID Mission], "Facilitator" => %w[Classification Status FacilitatorSinceMonth FacilitatorSinceYear FacilitatorStatusPending - HowTrained TrainingMotivation LifeExperiences PrimaryAgeGroup PrimaryServiceArea - OtherServiceArea Settings BBSLicense], + HowTrained TrainingMotivation LifeExperiences Settings BBSLicense], + "Sector & age group" => %w[PrimaryServiceArea OtherServiceArea PrimaryAgeGroup], "Notes" => %w[Comments NameNote Keywords Interest SourceOfName], "Volunteering" => %w[VolEnrollDate DropDate AvailMon AvailTue AvailWed AvailThu AvailFri AvailSat], "Giving" => %w[DonationCount DonationTotal Amount CkNo FunderPotential CallForArtRecord], diff --git a/config/features.yml b/config/features.yml index 0939572d4d..5bb90ce3fd 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4282,7 +4282,7 @@ action_path: "/fm_archives?table=rolodexes" summary: >- A FileMaker rolodex record now shows its fields in labeled sections — - identity, organization, contact, notes, facilitator status and history, + identity, organization, contact, sector and age group, facilitator history, and more — with the most useful details first. pro_tips: - "FileMaker's calculated and summary fields sit at the bottom under Other fields." From d0a98c3c23c26e0dfb48fc5815a1e6195f1af342 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 07:33:19 -0400 Subject: [PATCH 09/36] Put email with identity and give phones and addresses their own card Email is how admins tell people apart; phones and addresses are the lists they scan to reach someone. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/fm_rolodex.rb | 8 +++++--- app/views/fm_archives/show.html.erb | 2 +- config/features.yml | 4 ++-- spec/models/fm_rolodex_spec.rb | 5 ++--- spec/requests/fm_archives_spec.rb | 4 ++-- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index b2b08e28f5..2fef77cf13 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -101,10 +101,12 @@ class FmRolodex < ApplicationRecord "WorksiteAddrsID" => "fm_addresses" }.freeze + PHONES_AND_ADDRESSES = "Phones & addresses" + FIELD_GROUPS = { - "Identity" => %w[NamePrefix FirstName MiddleName LastName Suffix Pronouns Gender Gender2ndPerson isAgency__c], - "Contact" => %w[EMail EmailPrimary EmailType EmailNotes PrimaryPhoneID BestTimeToCall - PrimaryAddrsID WorksiteAddrsID WebSite], + "Identity" => %w[NamePrefix FirstName MiddleName LastName Suffix Pronouns Gender Gender2ndPerson isAgency__c + EMail EmailPrimary EmailType EmailNotes WebSite], + PHONES_AND_ADDRESSES => %w[PrimaryPhoneID BestTimeToCall PrimaryAddrsID WorksiteAddrsID], "Organization & role" => %w[Organization OrgID Title Department Dept2ndName PrimaryContact PrimaryContactID Mission], "Facilitator" => %w[Classification Status FacilitatorSinceMonth FacilitatorSinceYear FacilitatorStatusPending HowTrained TrainingMotivation LifeExperiences Settings BBSLicense], diff --git a/app/views/fm_archives/show.html.erb b/app/views/fm_archives/show.html.erb index d620c40c88..7cfc935773 100644 --- a/app/views/fm_archives/show.html.erb +++ b/app/views/fm_archives/show.html.erb @@ -45,7 +45,7 @@ <% end %>
    - <% if label == "Contact" && @record.is_a?(FmRolodex) %> + <% if @record.is_a?(FmRolodex) && label == FmRolodex::PHONES_AND_ADDRESSES %> <%= render "contact_records", rolodex: @record.decorate, heading: "Phone numbers", records: @record.phone_numbers %> <%= render "contact_records", rolodex: @record.decorate, heading: "Addresses", records: @record.addresses %> <% end %> diff --git a/config/features.yml b/config/features.yml index 5bb90ce3fd..28ad5c8d3c 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4282,9 +4282,9 @@ action_path: "/fm_archives?table=rolodexes" summary: >- A FileMaker rolodex record now shows its fields in labeled sections — - identity, organization, contact, sector and age group, facilitator history, + identity and email, phones and addresses, organization, sector and age group, and more — with the most useful details first. pro_tips: - "FileMaker's calculated and summary fields sit at the bottom under Other fields." - - "The Contact section lists every phone number and address on file for the person, with the primary ones first and tagged." + - "The Phones & addresses section lists every phone number and address on file for the person, with the primary ones first and tagged." - "The blue section at the bottom lists every field a rolodex record can hold and every table that links to it, even when this record leaves them blank." diff --git a/spec/models/fm_rolodex_spec.rb b/spec/models/fm_rolodex_spec.rb index b485691563..6b658d9e56 100644 --- a/spec/models/fm_rolodex_spec.rb +++ b/spec/models/fm_rolodex_spec.rb @@ -6,11 +6,10 @@ rolodex = described_class.new(data: { "Comments" => "Met at training", "LastName" => "Archer", "EMail" => "abe@example.com", "FirstName" => "Abe" }) expect(rolodex.grouped_data).to eq( - "Identity" => { "FirstName" => "Abe", "LastName" => "Archer" }, - "Contact" => { "EMail" => "abe@example.com" }, + "Identity" => { "FirstName" => "Abe", "LastName" => "Archer", "EMail" => "abe@example.com" }, "Notes" => { "Comments" => "Met at training" } ) - expect(rolodex.grouped_data["Identity"].keys).to eq(%w[FirstName LastName]) + expect(rolodex.grouped_data["Identity"].keys).to eq(%w[FirstName LastName EMail]) end it "trails fields no group names under other fields" do diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index 6102ad1133..d95a6eb540 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -25,14 +25,14 @@ expect(response.body).to match(/Identity.*LastName.*Notes.*Comments.*Record keeping/m) end - it "lists a rolodex record's phones and addresses under contact" do + it "lists a rolodex record's phones and addresses in their own card" do FmRolodex.create!(fm_id: "00006", data: { "ID" => "00006", "EMail" => "abe@example.com", "PrimaryPhoneID" => "PH1" }) FmPhoneNumber.create!(fm_id: "PH1", data: { "RolodexID" => "00006", "PhoneType" => "Work", "PhoneNumber" => "(213) 555-0101" }) FmAddress.create!(fm_id: "AD1", data: { "RolodexID" => "00006", "AddrsLine1" => "88 Rose Ave", "AddrsCity" => "Venice" }) get fm_archive_path("00006", table: "rolodexes") - expect(response.body).to match(/Contact.*Phone numbers \(1\).*\(213\) 555-0101.*Primary.*Addresses \(1\).*88 Rose Ave, Venice.*Record keeping/m) + expect(response.body).to match(/Phones & addresses.*Phone numbers \(1\).*\(213\) 555-0101.*Primary.*Addresses \(1\).*88 Rose Ave, Venice.*Record keeping/m) expect(response.body).to include("table=phone_numbers", "table=addresses") end From fad68800b1ce812ef0108356437df80e8045d330 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 07:35:09 -0400 Subject: [PATCH 10/36] Keep best time to call with identity, matching the portal The portal stores it on the person, alongside email, so grouping it there makes mapping archive fields onto people straightforward. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/fm_rolodex.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index 2fef77cf13..b24f19db85 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -105,8 +105,8 @@ class FmRolodex < ApplicationRecord FIELD_GROUPS = { "Identity" => %w[NamePrefix FirstName MiddleName LastName Suffix Pronouns Gender Gender2ndPerson isAgency__c - EMail EmailPrimary EmailType EmailNotes WebSite], - PHONES_AND_ADDRESSES => %w[PrimaryPhoneID BestTimeToCall PrimaryAddrsID WorksiteAddrsID], + EMail EmailPrimary EmailType EmailNotes WebSite BestTimeToCall], + PHONES_AND_ADDRESSES => %w[PrimaryPhoneID PrimaryAddrsID WorksiteAddrsID], "Organization & role" => %w[Organization OrgID Title Department Dept2ndName PrimaryContact PrimaryContactID Mission], "Facilitator" => %w[Classification Status FacilitatorSinceMonth FacilitatorSinceYear FacilitatorStatusPending HowTrained TrainingMotivation LifeExperiences Settings BBSLicense], From 5ec37d4991ef97cfd5584b569190946129132dac Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 07:37:11 -0400 Subject: [PATCH 11/36] Hide blank rolodex fields from the cards, and show street numbers Most of a rolodex record's ~90 fields are blank, which buried the filled ones; the reference section keeps the full list so nothing is lost. FileMaker stores some street numbers apart from the street line. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/decorators/fm_address_decorator.rb | 10 +++++++++- app/models/concerns/fm_archive.rb | 20 ++++++++++++++----- app/models/fm_rolodex.rb | 8 ++++++-- .../fm_archives/_field_reference.html.erb | 7 ++++--- app/views/fm_archives/show.html.erb | 2 +- config/features.yml | 2 +- spec/decorators/fm_address_decorator_spec.rb | 12 +++++++++++ spec/models/fm_rolodex_spec.rb | 13 ++++++++++++ spec/requests/fm_archives_spec.rb | 13 ++++++++++++ 9 files changed, 74 insertions(+), 13 deletions(-) diff --git a/app/decorators/fm_address_decorator.rb b/app/decorators/fm_address_decorator.rb index 26ef13bc6e..22b0b17466 100644 --- a/app/decorators/fm_address_decorator.rb +++ b/app/decorators/fm_address_decorator.rb @@ -5,6 +5,14 @@ def kind def summary state_and_zip = data.values_at("AddrsState", "AddrsPostalCode").compact_blank.join(" ") - [ *data.values_at("AddrsLine1", "AddrsLine2", "AddrsCity"), state_and_zip, data["AddrsCountry"] ].compact_blank.join(", ") + [ street, *data.values_at("AddrsLine2", "AddrsCity"), state_and_zip, data["AddrsCountry"] ].compact_blank.join(", ") + end + + def street + street_no = data["StreetNo"].to_s.strip + line1 = data["AddrsLine1"].to_s.strip + return line1 if street_no.empty? || line1.split.first == street_no + + [ street_no, line1 ].compact_blank.join(" ") end end diff --git a/app/models/concerns/fm_archive.rb b/app/models/concerns/fm_archive.rb index 2c9aa4bbe6..afa1b6e100 100644 --- a/app/models/concerns/fm_archive.rb +++ b/app/models/concerns/fm_archive.rb @@ -2,6 +2,7 @@ module FmArchive extend ActiveSupport::Concern FIELD_GROUPS = {}.freeze + OTHER_FIELDS = "Other fields" included do before_validation :set_defaults @@ -17,19 +18,28 @@ def find_by_data(column, value) end end - # The data fields clustered under FIELD_GROUPS headings, in their listed order; fields no group names - # trail under "Other fields". Ungrouped tables come back as one unlabeled group. + # The filled-in data fields clustered under FIELD_GROUPS headings, in their listed order; fields no group + # names trail under "Other fields". Ungrouped tables come back whole, as one unlabeled group. def grouped_data groups = self.class::FIELD_GROUPS return { nil => data } if groups.empty? - groups.transform_values { |keys| data.slice(*keys) } - .merge("Other fields" => data.except(*groups.values.flatten)) - .compact_blank + filled = data.compact_blank + groups.transform_values { |keys| filled.slice(*keys) } + .merge(OTHER_FIELDS => filled.slice(*ungrouped_keys)) + .select { |label, fields| fields.any? || keep_empty_group?(label) } + end + + def ungrouped_keys + data.keys - self.class::FIELD_GROUPS.values.flatten end private + def keep_empty_group?(_label) + false + end + def set_defaults self.data ||= {} end diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index b24f19db85..d6ef67adb1 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -147,15 +147,19 @@ class FmRolodex < ApplicationRecord }.freeze def phone_numbers - contact_records(FmPhoneNumber, primary_ids: data.values_at("PrimaryPhoneID")) + @phone_numbers ||= contact_records(FmPhoneNumber, primary_ids: data.values_at("PrimaryPhoneID")) end def addresses - contact_records(FmAddress, primary_ids: data.values_at("PrimaryAddrsID", "WorksiteAddrsID")) + @addresses ||= contact_records(FmAddress, primary_ids: data.values_at("PrimaryAddrsID", "WorksiteAddrsID")) end private + def keep_empty_group?(label) + label == PHONES_AND_ADDRESSES && (phone_numbers.any? || addresses.any?) + end + # Ones this record points at are listed first, even when their RolodexID names someone else. def contact_records(model, primary_ids:) primary_ids = primary_ids.compact_blank diff --git a/app/views/fm_archives/_field_reference.html.erb b/app/views/fm_archives/_field_reference.html.erb index 5256a48180..e51e173d4f 100644 --- a/app/views/fm_archives/_field_reference.html.erb +++ b/app/views/fm_archives/_field_reference.html.erb @@ -1,16 +1,17 @@ +<% model = record.class %>

    Available fields & associations

    -

    Everything a <%= model.name %> record can hold, whether or not this one has it filled in.

    +

    Every field on a <%= model.name %> record. Grayed-out fields are blank on this one, so the cards above leave them out.

    Fields

    - <% model::FIELD_GROUPS.each do |label, keys| %> + <% model::FIELD_GROUPS.merge(FmArchive::OTHER_FIELDS => record.ungrouped_keys).compact_blank.each do |label, keys| %>
    <%= label %>
    <% keys.each do |key| %> - <%= key %> + "><%= key %> <% end %>
    diff --git a/app/views/fm_archives/show.html.erb b/app/views/fm_archives/show.html.erb index 7cfc935773..d19602ad57 100644 --- a/app/views/fm_archives/show.html.erb +++ b/app/views/fm_archives/show.html.erb @@ -77,7 +77,7 @@ <% end %> <% if @record.class::FIELD_GROUPS.any? %> - <%= render "field_reference", model: @record.class %> + <%= render "field_reference", record: @record %> <% end %>
    diff --git a/config/features.yml b/config/features.yml index 28ad5c8d3c..389dc42219 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4287,4 +4287,4 @@ pro_tips: - "FileMaker's calculated and summary fields sit at the bottom under Other fields." - "The Phones & addresses section lists every phone number and address on file for the person, with the primary ones first and tagged." - - "The blue section at the bottom lists every field a rolodex record can hold and every table that links to it, even when this record leaves them blank." + - "Blank fields are left out of the cards. The blue section at the bottom still lists every field, grayed out when blank, plus every table that links to the person." diff --git a/spec/decorators/fm_address_decorator_spec.rb b/spec/decorators/fm_address_decorator_spec.rb index d6308c2e0c..b824e23269 100644 --- a/spec/decorators/fm_address_decorator_spec.rb +++ b/spec/decorators/fm_address_decorator_spec.rb @@ -8,6 +8,18 @@ expect(address.summary).to eq("88 Rose Ave, Venice, CA 90291, USA") end + it "leads with the street number FileMaker stores apart from the street" do + address = FmAddress.new(data: { "StreetNo" => "88", "AddrsLine1" => "Rose Ave", "AddrsCity" => "Venice" }).decorate + + expect(address.summary).to eq("88 Rose Ave, Venice") + end + + it "skips a street number the street line already starts with" do + address = FmAddress.new(data: { "StreetNo" => "88", "AddrsLine1" => "88 Rose Ave", "AddrsCity" => "Venice" }).decorate + + expect(address.summary).to eq("88 Rose Ave, Venice") + end + it "falls back to a generic kind when the type is blank" do expect(FmAddress.new(data: { "AddrsType" => "" }).decorate.kind).to eq("Address") end diff --git a/spec/models/fm_rolodex_spec.rb b/spec/models/fm_rolodex_spec.rb index 6b658d9e56..77ad981b87 100644 --- a/spec/models/fm_rolodex_spec.rb +++ b/spec/models/fm_rolodex_spec.rb @@ -21,6 +21,19 @@ ) end + it "leaves out blank fields and the groups they empty" do + rolodex = described_class.new(data: { "FirstName" => "Abe", "MiddleName" => "", "Comments" => nil, "zcFullName" => "" }) + + expect(rolodex.grouped_data).to eq("Identity" => { "FirstName" => "Abe" }) + end + + it "keeps an empty phones and addresses group when the person has phones on file" do + rolodex = described_class.create!(fm_id: "00901", data: { "PrimaryPhoneID" => "" }) + FmPhoneNumber.create!(fm_id: "PH1", data: { "RolodexID" => "00901" }) + + expect(rolodex.grouped_data).to eq(FmRolodex::PHONES_AND_ADDRESSES => {}) + end + it "groups every documented FileMaker field exactly once" do documented = File.read(Rails.root.join("app/models/fm_rolodex.rb")).scan(/^# (\w+)/).flatten grouped = described_class::FIELD_GROUPS.values.flatten diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index d95a6eb540..63642ea7c0 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -44,6 +44,19 @@ expect(response.body).to match(/Record keeping.*Available fields & associations.*FacilitatorSinceYear.*OrgID.*Organizations.*Workshops \(Authored\).*AuthorID/m) end + it "hides blank fields from the cards but keeps them, grayed, in the field reference" do + FmRolodex.create!(fm_id: "00006", data: { "ID" => "00006", "LastName" => "Archer", "MiddleName" => "", "zcSort" => "" }) + + get fm_archive_path("00006", table: "rolodexes") + + cards, reference = response.body.split("Available fields & associations") + expect(cards).to include("LastName") + expect(cards).not_to include("MiddleName", "zcSort") + expect(reference).to match(/text-gray-400">MiddleNameLastName Date: Tue, 13 Oct 2026 07:37:56 -0400 Subject: [PATCH 12/36] Show each phone and address's FileMaker ID beside its type So the primary and worksite ID fields above can be matched to the readable number or address by eye. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/views/fm_archives/_contact_records.html.erb | 5 ++++- spec/requests/fm_archives_spec.rb | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/views/fm_archives/_contact_records.html.erb b/app/views/fm_archives/_contact_records.html.erb index 931b2881fd..d8dd2bbe6a 100644 --- a/app/views/fm_archives/_contact_records.html.erb +++ b/app/views/fm_archives/_contact_records.html.erb @@ -3,7 +3,10 @@
      <% records.each do |record| %>
    • - <%= record.decorate.kind %> + + <%= record.decorate.kind %> + <%= record.fm_id %> + <%= link_to record.decorate.summary.presence || record.fm_id, fm_archive_path(record.fm_id, table: record.class.table_name.delete_prefix("fm_")), diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index 63642ea7c0..da355c5eb0 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -34,6 +34,7 @@ expect(response.body).to match(/Phones & addresses.*Phone numbers \(1\).*\(213\) 555-0101.*Primary.*Addresses \(1\).*88 Rose Ave, Venice.*Record keeping/m) expect(response.body).to include("table=phone_numbers", "table=addresses") + expect(response.body).to match(%r{Work\s*]*>PH1}) end it "lists every field and association a rolodex record can hold below its cards" do From 98bbd7329a0d4c250eacb40ac9d384176fa718bb Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 07:44:27 -0400 Subject: [PATCH 13/36] Strike through archive IDs that point at no FileMaker record A dead reference looked like a working link and led to 'No record found'. Website moves to the organization, where the portal keeps it. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/helpers/filemaker_helper.rb | 9 +++++++++ app/models/fm_rolodex.rb | 5 +++-- app/views/fm_archives/show.html.erb | 6 +++--- spec/helpers/filemaker_helper_spec.rb | 16 ++++++++++++++++ spec/requests/fm_archives_spec.rb | 11 +++++++++++ 5 files changed, 42 insertions(+), 5 deletions(-) diff --git a/app/helpers/filemaker_helper.rb b/app/helpers/filemaker_helper.rb index 349b083da5..94939d6f32 100644 --- a/app/helpers/filemaker_helper.rb +++ b/app/helpers/filemaker_helper.rb @@ -41,6 +41,15 @@ def filemaker_portal_link(record) link_to text, path, class: button_classes(:primary_outline, size: :sm) end + # An archive field's link to the record it names, struck through when the archive has no such record. + def fm_archive_reference_link(fm_id, table) + key = table.delete_prefix("fm_") + model = FmArchivesController::TABLES.dig(key, :model) + return tag.span(fm_id, class: "line-through opacity-60", title: "No FM record found") if model && !model.exists?(fm_id: fm_id) + + link_to fm_id, fm_archive_path(fm_id, table: key), class: "text-primary hover:underline" + end + # A code no archive table holds renders struck through, flagging a likely typo — # or is dropped with `linked_only`, for nav buttons that must all lead somewhere. def filemaker_archive_links(codes, archives:, link_class: nil, label: nil, linked_only: false) diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index d6ef67adb1..558ae952e8 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -105,9 +105,10 @@ class FmRolodex < ApplicationRecord FIELD_GROUPS = { "Identity" => %w[NamePrefix FirstName MiddleName LastName Suffix Pronouns Gender Gender2ndPerson isAgency__c - EMail EmailPrimary EmailType EmailNotes WebSite BestTimeToCall], + EMail EmailPrimary EmailType EmailNotes BestTimeToCall], PHONES_AND_ADDRESSES => %w[PrimaryPhoneID PrimaryAddrsID WorksiteAddrsID], - "Organization & role" => %w[Organization OrgID Title Department Dept2ndName PrimaryContact PrimaryContactID Mission], + "Organization & role" => %w[Organization OrgID Title Department Dept2ndName PrimaryContact PrimaryContactID + WebSite Mission], "Facilitator" => %w[Classification Status FacilitatorSinceMonth FacilitatorSinceYear FacilitatorStatusPending HowTrained TrainingMotivation LifeExperiences Settings BBSLicense], "Sector & age group" => %w[PrimaryServiceArea OtherServiceArea PrimaryAgeGroup], diff --git a/app/views/fm_archives/show.html.erb b/app/views/fm_archives/show.html.erb index d19602ad57..84cacb3c03 100644 --- a/app/views/fm_archives/show.html.erb +++ b/app/views/fm_archives/show.html.erb @@ -33,11 +33,11 @@
      <% link_target = @record.class::FM_LINKS[key] %> <% if link_target && value.present? %> - <%= link_to value, fm_archive_path(value, table: link_target.sub("fm_", "")), class: "text-primary hover:underline" %> + <%= fm_archive_reference_link(value, link_target) %> <% elsif link_target.nil? && value.present? && @record.class.const_defined?(:CONTEXT_LINK_MAP) && (ctx = @record.data["Context"]) && (ctx_target = @record.class::CONTEXT_LINK_MAP[ctx]) %> - <%= link_to value, fm_archive_path(value, table: ctx_target.sub("fm_", "")), class: "text-primary hover:underline" %> + <%= fm_archive_reference_link(value, ctx_target) %> <% elsif link_target.nil? && value.present? && @record.class.const_defined?(:PREFIX_LINK_MAP) && (prefix_target = @record.class::PREFIX_LINK_MAP[value.to_s[0]]) %> - <%= link_to value, fm_archive_path(value, table: prefix_target.sub("fm_", "")), class: "text-primary hover:underline" %> + <%= fm_archive_reference_link(value, prefix_target) %> <% else %> <%= value %> <% end %> diff --git a/spec/helpers/filemaker_helper_spec.rb b/spec/helpers/filemaker_helper_spec.rb index 777d2cf852..65155f79c9 100644 --- a/spec/helpers/filemaker_helper_spec.rb +++ b/spec/helpers/filemaker_helper_spec.rb @@ -84,6 +84,22 @@ end end + describe "#fm_archive_reference_link" do + it "links an ID the archive holds to its record" do + FmAddress.create!(fm_id: "74001") + + expect(helper.fm_archive_reference_link("74001", "fm_addresses")) + .to include(%(href="#{fm_archive_path("74001", table: "addresses")}")) + end + + it "strikes through an ID the archive has no record for" do + html = helper.fm_archive_reference_link("74999", "fm_addresses") + + expect(html).to include("line-through", %(title="No FM record found"), "74999") + expect(html).not_to include("href") + end + end + describe "#filemaker_code_hint" do before { FmRolodex.create!(fm_id: "00006") } diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index da355c5eb0..fd86546d6c 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -58,6 +58,17 @@ expect(reference).to match(/Other fields.*zcSort/m) end + it "strikes through a linked ID with no archive record, and links the rest" do + FmRolodex.create!(fm_id: "00006", data: { "ID" => "00006", "PrimaryAddrsID" => "74999", "PrimaryPhoneID" => "75001" }) + FmPhoneNumber.create!(fm_id: "75001") + + get fm_archive_path("00006", table: "rolodexes") + + expect(response.body).to match(%r{]*line-through[^>]*title="No FM record found"[^>]*>74999}) + expect(response.body).to include(fm_archive_path("75001", table: "phone_numbers")) + expect(response.body).not_to include(fm_archive_path("74999", table: "addresses")) + end + it "leaves the field reference off tables without field groups" do FmProject.create!(fm_id: "P2775") From 84de1b15ae15b8d245bbcc40fda49a2f8c074084 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 07:47:52 -0400 Subject: [PATCH 14/36] Split rolodex cards into subcards and list dead phone/address IDs Organization, job and facilitator affiliations are separate records in the portal, so subcards make the archive map onto them. Primary phone and address IDs read better beside the record they name, including when FileMaker has no such record. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/helpers/filemaker_helper.rb | 2 +- app/models/concerns/fm_archive.rb | 36 +++++++++------ app/models/fm_rolodex.rb | 40 +++++++++++------ .../fm_archives/_contact_records.html.erb | 23 ++++++---- .../fm_archives/_field_reference.html.erb | 2 +- app/views/fm_archives/_fields.html.erb | 19 ++++++++ app/views/fm_archives/show.html.erb | 32 +++++--------- config/features.yml | 6 ++- spec/models/fm_rolodex_spec.rb | 44 ++++++++++++++----- spec/requests/fm_archives_spec.rb | 24 +++++++--- 10 files changed, 151 insertions(+), 77 deletions(-) create mode 100644 app/views/fm_archives/_fields.html.erb diff --git a/app/helpers/filemaker_helper.rb b/app/helpers/filemaker_helper.rb index 94939d6f32..82f7c5decd 100644 --- a/app/helpers/filemaker_helper.rb +++ b/app/helpers/filemaker_helper.rb @@ -38,7 +38,7 @@ def filemaker_portal_link(record) when Workshop then [ record.title, workshop_path(record) ] end - link_to text, path, class: button_classes(:primary_outline, size: :sm) + link_to text, path, class: button_classes(:primary) end # An archive field's link to the record it names, struck through when the archive has no such record. diff --git a/app/models/concerns/fm_archive.rb b/app/models/concerns/fm_archive.rb index afa1b6e100..751a3563da 100644 --- a/app/models/concerns/fm_archive.rb +++ b/app/models/concerns/fm_archive.rb @@ -3,6 +3,7 @@ module FmArchive FIELD_GROUPS = {}.freeze OTHER_FIELDS = "Other fields" + LISTED_FIELDS = [].freeze included do before_validation :set_defaults @@ -16,30 +17,39 @@ def find_by_fm_id(id) def find_by_data(column, value) where("JSON_UNQUOTE(JSON_EXTRACT(data, CONCAT('$.', ?))) = ?", column.to_s, value) end + + # FIELD_GROUPS with each card's subcards lifted to the top level. + def flat_field_groups + self::FIELD_GROUPS.flat_map { |label, keys| keys.is_a?(Hash) ? keys.to_a : [ [ label, keys ] ] }.to_h + end end - # The filled-in data fields clustered under FIELD_GROUPS headings, in their listed order; fields no group - # names trail under "Other fields". Ungrouped tables come back whole, as one unlabeled group. + # The filled-in data fields as cards of subcards, { card => { subcard => fields } }, in FIELD_GROUPS order. + # A card listing its fields directly holds one unlabeled subcard; fields no group names trail under + # "Other fields". Ungrouped tables come back whole, as one unlabeled card. def grouped_data groups = self.class::FIELD_GROUPS - return { nil => data } if groups.empty? - - filled = data.compact_blank - groups.transform_values { |keys| filled.slice(*keys) } - .merge(OTHER_FIELDS => filled.slice(*ungrouped_keys)) - .select { |label, fields| fields.any? || keep_empty_group?(label) } + return { nil => { nil => data } } if groups.empty? + + filled = data.compact_blank.except(*self.class::LISTED_FIELDS) + groups.merge(OTHER_FIELDS => ungrouped_keys).to_h do |label, keys| + subgroups = keys.is_a?(Hash) ? keys : { nil => keys } + [ label, subgroups.transform_values { |sub_keys| filled.slice(*sub_keys) } + .select { |sub_label, fields| fields.any? || records_listed_under(sub_label).any? } ] + end.compact_blank end def ungrouped_keys - data.keys - self.class::FIELD_GROUPS.values.flatten + data.keys - self.class.flat_field_groups.values.flatten end - private - - def keep_empty_group?(_label) - false + # Related records a subcard lists below its fields. + def records_listed_under(_label) + [] end + private + def set_defaults self.data ||= {} end diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index 558ae952e8..8d8e084a67 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -101,23 +101,31 @@ class FmRolodex < ApplicationRecord "WorksiteAddrsID" => "fm_addresses" }.freeze - PHONES_AND_ADDRESSES = "Phones & addresses" + PHONES = "Phones" + ADDRESSES = "Addresses" + LISTED_FIELDS = %w[PrimaryPhoneID PrimaryAddrsID WorksiteAddrsID].freeze FIELD_GROUPS = { "Identity" => %w[NamePrefix FirstName MiddleName LastName Suffix Pronouns Gender Gender2ndPerson isAgency__c EMail EmailPrimary EmailType EmailNotes BestTimeToCall], - PHONES_AND_ADDRESSES => %w[PrimaryPhoneID PrimaryAddrsID WorksiteAddrsID], - "Organization & role" => %w[Organization OrgID Title Department Dept2ndName PrimaryContact PrimaryContactID - WebSite Mission], - "Facilitator" => %w[Classification Status FacilitatorSinceMonth FacilitatorSinceYear FacilitatorStatusPending - HowTrained TrainingMotivation LifeExperiences Settings BBSLicense], + "Phones & addresses" => { + PHONES => %w[PrimaryPhoneID], + ADDRESSES => %w[PrimaryAddrsID WorksiteAddrsID MailingList] + }, + "Organization & affiliations" => { + "Organization" => %w[Organization OrgID WebSite Mission PrimaryContact PrimaryContactID], + "Job affiliation" => %w[Title Department Dept2ndName], + "Facilitator affiliation" => %w[Classification Status FacilitatorSinceMonth FacilitatorSinceYear + FacilitatorStatusPending HowTrained TrainingMotivation LifeExperiences + Settings BBSLicense] + }, "Sector & age group" => %w[PrimaryServiceArea OtherServiceArea PrimaryAgeGroup], "Notes" => %w[Comments NameNote Keywords Interest SourceOfName], "Volunteering" => %w[VolEnrollDate DropDate AvailMon AvailTue AvailWed AvailThu AvailFri AvailSat], "Giving" => %w[DonationCount DonationTotal Amount CkNo FunderPotential CallForArtRecord], "Personal details" => %w[DateOfBirth BirthMonth BirthDay BirthYear BirthdayFlag Anniversary Children Ethnicity Languages], - "Mailing" => %w[MailingList AddresseeGreetingStyles ListBy IncludeTitle FamilyCode Private], + "Mailing" => %w[AddresseeGreetingStyles ListBy IncludeTitle FamilyCode Private], "Record keeping" => %w[ID Delete DuplicateStatus EnteredBy DateEntered ModifiedBy DateTimeModified UserID Password] }.freeze @@ -155,16 +163,22 @@ def addresses @addresses ||= contact_records(FmAddress, primary_ids: data.values_at("PrimaryAddrsID", "WorksiteAddrsID")) end - private - - def keep_empty_group?(label) - label == PHONES_AND_ADDRESSES && (phone_numbers.any? || addresses.any?) + def records_listed_under(label) + case label + when PHONES then phone_numbers + when ADDRESSES then addresses + else [] + end end - # Ones this record points at are listed first, even when their RolodexID names someone else. + private + + # Ones this record points at are listed first, even when their RolodexID names someone else; one the + # archive lacks stands in as an unsaved placeholder. def contact_records(model, primary_ids:) - primary_ids = primary_ids.compact_blank + primary_ids = primary_ids.compact_blank.uniq records = model.find_by_data("RolodexID", fm_id).or(model.where(fm_id: primary_ids)).order(:fm_id).to_a + records += (primary_ids - records.map(&:fm_id)).map { |id| model.new(fm_id: id, data: {}) } records.sort_by { |record| primary_ids.index(record.fm_id) || primary_ids.size } end end diff --git a/app/views/fm_archives/_contact_records.html.erb b/app/views/fm_archives/_contact_records.html.erb index d8dd2bbe6a..9b21866347 100644 --- a/app/views/fm_archives/_contact_records.html.erb +++ b/app/views/fm_archives/_contact_records.html.erb @@ -1,18 +1,25 @@ <% if records.any? %> -

      <%= heading %> (<%= records.size %>)

      -
        +
          <% records.each do |record| %>
        • <%= record.decorate.kind %> - <%= record.fm_id %> + <% if record.persisted? %> + <%= record.fm_id %> + <% else %> + <%= record.fm_id %> + <% end %> + <% owner.decorate.contact_tags(record).each do |tag| %> + <%= tag %> + <% end %> - <%= link_to record.decorate.summary.presence || record.fm_id, - fm_archive_path(record.fm_id, table: record.class.table_name.delete_prefix("fm_")), - class: "text-primary hover:underline" %> - <% rolodex.contact_tags(record).each do |tag| %> - <%= tag %> + <% if record.persisted? %> + <%= link_to record.decorate.summary.presence || record.fm_id, + fm_archive_path(record.fm_id, table: record.class.table_name.delete_prefix("fm_")), + class: "text-primary hover:underline" %> + <% else %> + Not found in FM data <% end %>
        • diff --git a/app/views/fm_archives/_field_reference.html.erb b/app/views/fm_archives/_field_reference.html.erb index e51e173d4f..b96cf1f99a 100644 --- a/app/views/fm_archives/_field_reference.html.erb +++ b/app/views/fm_archives/_field_reference.html.erb @@ -6,7 +6,7 @@

          Fields

          - <% model::FIELD_GROUPS.merge(FmArchive::OTHER_FIELDS => record.ungrouped_keys).compact_blank.each do |label, keys| %> + <% model.flat_field_groups.merge(FmArchive::OTHER_FIELDS => record.ungrouped_keys).compact_blank.each do |label, keys| %>
          <%= label %>
          diff --git a/app/views/fm_archives/_fields.html.erb b/app/views/fm_archives/_fields.html.erb new file mode 100644 index 0000000000..b49f4b53e1 --- /dev/null +++ b/app/views/fm_archives/_fields.html.erb @@ -0,0 +1,19 @@ +
          + <% fields.each do |key, value| %> +
          +
          <%= key %>
          +
          + <% link_target = record.class::FM_LINKS[key] %> + <% if link_target && value.present? %> + <%= fm_archive_reference_link(value, link_target) %> + <% elsif link_target.nil? && value.present? && record.class.const_defined?(:CONTEXT_LINK_MAP) && (ctx = record.data["Context"]) && (ctx_target = record.class::CONTEXT_LINK_MAP[ctx]) %> + <%= fm_archive_reference_link(value, ctx_target) %> + <% elsif link_target.nil? && value.present? && record.class.const_defined?(:PREFIX_LINK_MAP) && (prefix_target = record.class::PREFIX_LINK_MAP[value.to_s[0]]) %> + <%= fm_archive_reference_link(value, prefix_target) %> + <% else %> + <%= value %> + <% end %> +
          +
          + <% end %> +
          diff --git a/app/views/fm_archives/show.html.erb b/app/views/fm_archives/show.html.erb index 84cacb3c03..6131892dfd 100644 --- a/app/views/fm_archives/show.html.erb +++ b/app/views/fm_archives/show.html.erb @@ -21,34 +21,22 @@ <% end %>
          - <% @record.grouped_data.each do |label, fields| %> + <% @record.grouped_data.each do |label, subgroups| %>
          "> <% if label %>

          <%= label %>

          <% end %> -
          - <% fields.each do |key, value| %> -
          -
          <%= key %>
          -
          - <% link_target = @record.class::FM_LINKS[key] %> - <% if link_target && value.present? %> - <%= fm_archive_reference_link(value, link_target) %> - <% elsif link_target.nil? && value.present? && @record.class.const_defined?(:CONTEXT_LINK_MAP) && (ctx = @record.data["Context"]) && (ctx_target = @record.class::CONTEXT_LINK_MAP[ctx]) %> - <%= fm_archive_reference_link(value, ctx_target) %> - <% elsif link_target.nil? && value.present? && @record.class.const_defined?(:PREFIX_LINK_MAP) && (prefix_target = @record.class::PREFIX_LINK_MAP[value.to_s[0]]) %> - <%= fm_archive_reference_link(value, prefix_target) %> - <% else %> - <%= value %> - <% end %> -
          +
          + <% subgroups.each do |sub_label, fields| %> +
          "> + <% if sub_label %> +

          <%= sub_label %>

          + <% end %> + <%= render "fields", record: @record, fields: fields %> + <%= render "contact_records", owner: @record, records: @record.records_listed_under(sub_label) %>
          <% end %> -
          - <% if @record.is_a?(FmRolodex) && label == FmRolodex::PHONES_AND_ADDRESSES %> - <%= render "contact_records", rolodex: @record.decorate, heading: "Phone numbers", records: @record.phone_numbers %> - <%= render "contact_records", rolodex: @record.decorate, heading: "Addresses", records: @record.addresses %> - <% end %> +
    <% end %> diff --git a/config/features.yml b/config/features.yml index 389dc42219..c9a4e15526 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4282,9 +4282,11 @@ action_path: "/fm_archives?table=rolodexes" summary: >- A FileMaker rolodex record now shows its fields in labeled sections — - identity and email, phones and addresses, organization, sector and age group, - and more — with the most useful details first. + identity and email, phones and addresses, organization with job and + facilitator affiliations, sector and age group, and more — with the most + useful details first. pro_tips: - "FileMaker's calculated and summary fields sit at the bottom under Other fields." - "The Phones & addresses section lists every phone number and address on file for the person, with the primary ones first and tagged." + - "A struck-through ID points at a FileMaker record the archive doesn't have — hover it to confirm." - "Blank fields are left out of the cards. The blue section at the bottom still lists every field, grayed out when blank, plus every table that links to the person." diff --git a/spec/models/fm_rolodex_spec.rb b/spec/models/fm_rolodex_spec.rb index 77ad981b87..3c4f963974 100644 --- a/spec/models/fm_rolodex_spec.rb +++ b/spec/models/fm_rolodex_spec.rb @@ -6,37 +6,55 @@ rolodex = described_class.new(data: { "Comments" => "Met at training", "LastName" => "Archer", "EMail" => "abe@example.com", "FirstName" => "Abe" }) expect(rolodex.grouped_data).to eq( - "Identity" => { "FirstName" => "Abe", "LastName" => "Archer", "EMail" => "abe@example.com" }, - "Notes" => { "Comments" => "Met at training" } + "Identity" => { nil => { "FirstName" => "Abe", "LastName" => "Archer", "EMail" => "abe@example.com" } }, + "Notes" => { nil => { "Comments" => "Met at training" } } + ) + expect(rolodex.grouped_data.dig("Identity", nil).keys).to eq(%w[FirstName LastName EMail]) + end + + it "splits a card into its subcards" do + rolodex = described_class.new(data: { "Status" => "Active", "Title" => "Director", "Organization" => "Shelter" }) + + expect(rolodex.grouped_data).to eq( + "Organization & affiliations" => { + "Organization" => { "Organization" => "Shelter" }, + "Job affiliation" => { "Title" => "Director" }, + "Facilitator affiliation" => { "Status" => "Active" } + } ) - expect(rolodex.grouped_data["Identity"].keys).to eq(%w[FirstName LastName EMail]) end it "trails fields no group names under other fields" do rolodex = described_class.new(data: { "zcFullName" => "Abe Archer", "ID" => "00901" }) expect(rolodex.grouped_data).to eq( - "Record keeping" => { "ID" => "00901" }, - "Other fields" => { "zcFullName" => "Abe Archer" } + "Record keeping" => { nil => { "ID" => "00901" } }, + "Other fields" => { nil => { "zcFullName" => "Abe Archer" } } ) end it "leaves out blank fields and the groups they empty" do rolodex = described_class.new(data: { "FirstName" => "Abe", "MiddleName" => "", "Comments" => nil, "zcFullName" => "" }) - expect(rolodex.grouped_data).to eq("Identity" => { "FirstName" => "Abe" }) + expect(rolodex.grouped_data).to eq("Identity" => { nil => { "FirstName" => "Abe" } }) end - it "keeps an empty phones and addresses group when the person has phones on file" do + it "leaves the primary phone and address IDs to the lists below" do + rolodex = described_class.new(data: { "PrimaryAddrsID" => "AD1", "MailingList" => "Yes" }) + + expect(rolodex.grouped_data).to eq("Phones & addresses" => { FmRolodex::ADDRESSES => { "MailingList" => "Yes" } }) + end + + it "keeps an empty phones subcard when the person has phones on file" do rolodex = described_class.create!(fm_id: "00901", data: { "PrimaryPhoneID" => "" }) FmPhoneNumber.create!(fm_id: "PH1", data: { "RolodexID" => "00901" }) - expect(rolodex.grouped_data).to eq(FmRolodex::PHONES_AND_ADDRESSES => {}) + expect(rolodex.grouped_data).to eq("Phones & addresses" => { FmRolodex::PHONES => {} }) end it "groups every documented FileMaker field exactly once" do documented = File.read(Rails.root.join("app/models/fm_rolodex.rb")).scan(/^# (\w+)/).flatten - grouped = described_class::FIELD_GROUPS.values.flatten + grouped = described_class.flat_field_groups.values.flatten expect(grouped).to match_array(documented) end @@ -63,11 +81,17 @@ it "lists primary, then worksite, then other addresses, including a worksite filed under someone else" do expect(rolodex.addresses.map(&:fm_id)).to eq(%w[AD3 AD9 AD1]) end + + it "stands in an unsaved placeholder for a primary ID the archive lacks" do + rolodex.data["PrimaryPhoneID"] = "PH9" + + expect(rolodex.phone_numbers.map { |phone| [ phone.fm_id, phone.persisted? ] }).to eq([ [ "PH9", false ], [ "PH1", true ], [ "PH2", true ] ]) + end end it "leaves tables without field groups as one unlabeled group" do organization = FmOrganization.new(data: { "OrgName" => "Shelter" }) - expect(organization.grouped_data).to eq(nil => { "OrgName" => "Shelter" }) + expect(organization.grouped_data).to eq(nil => { nil => { "OrgName" => "Shelter" } }) end end diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index fd86546d6c..db0cc33248 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -32,9 +32,19 @@ get fm_archive_path("00006", table: "rolodexes") - expect(response.body).to match(/Phones & addresses.*Phone numbers \(1\).*\(213\) 555-0101.*Primary.*Addresses \(1\).*88 Rose Ave, Venice.*Record keeping/m) + expect(response.body).to match(/Phones & addresses.*Phones.*\(213\) 555-0101.*Addresses.*88 Rose Ave, Venice.*Record keeping/m) expect(response.body).to include("table=phone_numbers", "table=addresses") - expect(response.body).to match(%r{Work\s*]*>PH1
    }) + expect(response.body).to match(%r{Work\s*]*>PH1\s*]*>Primary}) + expect(response.body).not_to include("PrimaryPhoneID") + end + + it "lists a primary phone or address the archive lacks as not found" do + FmRolodex.create!(fm_id: "00006", data: { "ID" => "00006", "PrimaryPhoneID" => "75999", "WorksiteAddrsID" => "74999" }) + + get fm_archive_path("00006", table: "rolodexes") + + expect(response.body).to match(%r{]*line-through[^>]*title="No FM record found"[^>]*>75999\s*]*>Primary.*Not found in FM data}m) + expect(response.body).to match(%r{>74999\s*]*>Worksite.*Not found in FM data}m) end it "lists every field and association a rolodex record can hold below its cards" do @@ -59,14 +69,14 @@ end it "strikes through a linked ID with no archive record, and links the rest" do - FmRolodex.create!(fm_id: "00006", data: { "ID" => "00006", "PrimaryAddrsID" => "74999", "PrimaryPhoneID" => "75001" }) - FmPhoneNumber.create!(fm_id: "75001") + FmRolodex.create!(fm_id: "00006", data: { "ID" => "00006", "OrgID" => "ORG90999", "PrimaryContactID" => "00007" }) + FmRolodex.create!(fm_id: "00007") get fm_archive_path("00006", table: "rolodexes") - expect(response.body).to match(%r{]*line-through[^>]*title="No FM record found"[^>]*>74999}) - expect(response.body).to include(fm_archive_path("75001", table: "phone_numbers")) - expect(response.body).not_to include(fm_archive_path("74999", table: "addresses")) + expect(response.body).to match(%r{]*line-through[^>]*title="No FM record found"[^>]*>ORG90999}) + expect(response.body).to include(fm_archive_path("00007", table: "rolodexes")) + expect(response.body).not_to include(fm_archive_path("ORG90999", table: "organizations")) end it "leaves the field reference off tables without field groups" do From d32f462b2a7543aad71169c755a07a35b1d824d5 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 07:48:30 -0400 Subject: [PATCH 15/36] Give the rolodex mailing list flag its own subcard Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/fm_rolodex.rb | 3 ++- spec/models/fm_rolodex_spec.rb | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index 8d8e084a67..dcd6a876b8 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -110,7 +110,8 @@ class FmRolodex < ApplicationRecord EMail EmailPrimary EmailType EmailNotes BestTimeToCall], "Phones & addresses" => { PHONES => %w[PrimaryPhoneID], - ADDRESSES => %w[PrimaryAddrsID WorksiteAddrsID MailingList] + ADDRESSES => %w[PrimaryAddrsID WorksiteAddrsID], + "Mailing list" => %w[MailingList] }, "Organization & affiliations" => { "Organization" => %w[Organization OrgID WebSite Mission PrimaryContact PrimaryContactID], diff --git a/spec/models/fm_rolodex_spec.rb b/spec/models/fm_rolodex_spec.rb index 3c4f963974..b01dcb4e0e 100644 --- a/spec/models/fm_rolodex_spec.rb +++ b/spec/models/fm_rolodex_spec.rb @@ -42,7 +42,7 @@ it "leaves the primary phone and address IDs to the lists below" do rolodex = described_class.new(data: { "PrimaryAddrsID" => "AD1", "MailingList" => "Yes" }) - expect(rolodex.grouped_data).to eq("Phones & addresses" => { FmRolodex::ADDRESSES => { "MailingList" => "Yes" } }) + expect(rolodex.grouped_data).to eq("Phones & addresses" => { "Mailing list" => { "MailingList" => "Yes" } }) end it "keeps an empty phones subcard when the person has phones on file" do From 9354cb9f334ab22849491183d70ce479f3f6c276 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 07:49:33 -0400 Subject: [PATCH 16/36] Stack record keeping beside the organization card The organization card runs tall with its three subcards; stacking sector and record keeping beside it fills the space. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/concerns/fm_archive.rb | 1 + app/models/fm_rolodex.rb | 5 +++-- app/views/fm_archives/show.html.erb | 2 +- spec/models/fm_rolodex_spec.rb | 7 +++++-- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/models/concerns/fm_archive.rb b/app/models/concerns/fm_archive.rb index 751a3563da..db9cadcc38 100644 --- a/app/models/concerns/fm_archive.rb +++ b/app/models/concerns/fm_archive.rb @@ -4,6 +4,7 @@ module FmArchive FIELD_GROUPS = {}.freeze OTHER_FIELDS = "Other fields" LISTED_FIELDS = [].freeze + TALL_GROUPS = [].freeze included do before_validation :set_defaults diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index dcd6a876b8..d2cc6f3898 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -104,6 +104,7 @@ class FmRolodex < ApplicationRecord PHONES = "Phones" ADDRESSES = "Addresses" LISTED_FIELDS = %w[PrimaryPhoneID PrimaryAddrsID WorksiteAddrsID].freeze + TALL_GROUPS = [ "Organization & affiliations" ].freeze FIELD_GROUPS = { "Identity" => %w[NamePrefix FirstName MiddleName LastName Suffix Pronouns Gender Gender2ndPerson isAgency__c @@ -121,13 +122,13 @@ class FmRolodex < ApplicationRecord Settings BBSLicense] }, "Sector & age group" => %w[PrimaryServiceArea OtherServiceArea PrimaryAgeGroup], + "Record keeping" => %w[ID Delete DuplicateStatus EnteredBy DateEntered ModifiedBy DateTimeModified UserID Password], "Notes" => %w[Comments NameNote Keywords Interest SourceOfName], "Volunteering" => %w[VolEnrollDate DropDate AvailMon AvailTue AvailWed AvailThu AvailFri AvailSat], "Giving" => %w[DonationCount DonationTotal Amount CkNo FunderPotential CallForArtRecord], "Personal details" => %w[DateOfBirth BirthMonth BirthDay BirthYear BirthdayFlag Anniversary Children Ethnicity Languages], - "Mailing" => %w[AddresseeGreetingStyles ListBy IncludeTitle FamilyCode Private], - "Record keeping" => %w[ID Delete DuplicateStatus EnteredBy DateEntered ModifiedBy DateTimeModified UserID Password] + "Mailing" => %w[AddresseeGreetingStyles ListBy IncludeTitle FamilyCode Private] }.freeze HAS_MANY = { diff --git a/app/views/fm_archives/show.html.erb b/app/views/fm_archives/show.html.erb index 6131892dfd..a3722bb90c 100644 --- a/app/views/fm_archives/show.html.erb +++ b/app/views/fm_archives/show.html.erb @@ -22,7 +22,7 @@
    <% @record.grouped_data.each do |label, subgroups| %> -
    "> +
    "> <% if label %>

    <%= label %>

    <% end %> diff --git a/spec/models/fm_rolodex_spec.rb b/spec/models/fm_rolodex_spec.rb index b01dcb4e0e..97aab6bf99 100644 --- a/spec/models/fm_rolodex_spec.rb +++ b/spec/models/fm_rolodex_spec.rb @@ -40,9 +40,12 @@ end it "leaves the primary phone and address IDs to the lists below" do - rolodex = described_class.new(data: { "PrimaryAddrsID" => "AD1", "MailingList" => "Yes" }) + FmAddress.create!(fm_id: "AD1") + rolodex = described_class.new(fm_id: "00901", data: { "PrimaryAddrsID" => "AD1", "MailingList" => "Yes" }) - expect(rolodex.grouped_data).to eq("Phones & addresses" => { "Mailing list" => { "MailingList" => "Yes" } }) + expect(rolodex.grouped_data).to eq( + "Phones & addresses" => { FmRolodex::ADDRESSES => {}, "Mailing list" => { "MailingList" => "Yes" } } + ) end it "keeps an empty phones subcard when the person has phones on file" do From c6bdf671694e783f1cba991d77c6227cbc1c3fca Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:01:55 -0400 Subject: [PATCH 17/36] Link phone and address IDs, and show the mailing list flag on one row Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/concerns/fm_archive.rb | 5 +++++ .../fm_archives/_contact_records.html.erb | 3 ++- app/views/fm_archives/show.html.erb | 21 ++++++++++++------- spec/requests/fm_archives_spec.rb | 11 +++++++++- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/app/models/concerns/fm_archive.rb b/app/models/concerns/fm_archive.rb index db9cadcc38..a2babe3ebf 100644 --- a/app/models/concerns/fm_archive.rb +++ b/app/models/concerns/fm_archive.rb @@ -44,6 +44,11 @@ def ungrouped_keys data.keys - self.class.flat_field_groups.values.flatten end + # A subcard of exactly one field, which reads as a single labeled row. + def single_field_group?(label) + self.class.flat_field_groups[label]&.one? && records_listed_under(label).none? + end + # Related records a subcard lists below its fields. def records_listed_under(_label) [] diff --git a/app/views/fm_archives/_contact_records.html.erb b/app/views/fm_archives/_contact_records.html.erb index 9b21866347..80652052ea 100644 --- a/app/views/fm_archives/_contact_records.html.erb +++ b/app/views/fm_archives/_contact_records.html.erb @@ -5,7 +5,8 @@ <%= record.decorate.kind %> <% if record.persisted? %> - <%= record.fm_id %> + <%= link_to record.fm_id, fm_archive_path(record.fm_id, table: record.class.table_name.delete_prefix("fm_")), + class: "ml-1 font-mono text-xs font-normal text-primary hover:underline" %> <% else %> <%= record.fm_id %> <% end %> diff --git a/app/views/fm_archives/show.html.erb b/app/views/fm_archives/show.html.erb index a3722bb90c..df7e360bb4 100644 --- a/app/views/fm_archives/show.html.erb +++ b/app/views/fm_archives/show.html.erb @@ -28,13 +28,20 @@ <% end %>
    <% subgroups.each do |sub_label, fields| %> -
    "> - <% if sub_label %> -

    <%= sub_label %>

    - <% end %> - <%= render "fields", record: @record, fields: fields %> - <%= render "contact_records", owner: @record, records: @record.records_listed_under(sub_label) %> -
    + <% if sub_label && @record.single_field_group?(sub_label) %> +
    +

    <%= sub_label %>

    + <%= fields.values.first %> +
    + <% else %> +
    "> + <% if sub_label %> +

    <%= sub_label %>

    + <% end %> + <%= render "fields", record: @record, fields: fields %> + <%= render "contact_records", owner: @record, records: @record.records_listed_under(sub_label) %> +
    + <% end %> <% end %>
    diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index db0cc33248..2190a391f4 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -34,10 +34,19 @@ expect(response.body).to match(/Phones & addresses.*Phones.*\(213\) 555-0101.*Addresses.*88 Rose Ave, Venice.*Record keeping/m) expect(response.body).to include("table=phone_numbers", "table=addresses") - expect(response.body).to match(%r{Work\s*]*>PH1\s*]*>Primary}) + expect(response.body).to match(%r{Work\s*]*href="#{Regexp.escape(fm_archive_path("PH1", table: "phone_numbers"))}"[^>]*>PH1\s*]*>Primary}) expect(response.body).not_to include("PrimaryPhoneID") end + it "shows a one-field subcard as a single row, its heading beside the value" do + FmRolodex.create!(fm_id: "00006", data: { "ID" => "00006", "MailingList" => "Yes" }) + + get fm_archive_path("00006", table: "rolodexes") + + expect(response.body).to match(%r{]*>Mailing list\s*]*>Yes}) + expect(response.body).not_to include("MailingList") + end + it "lists a primary phone or address the archive lacks as not found" do FmRolodex.create!(fm_id: "00006", data: { "ID" => "00006", "PrimaryPhoneID" => "75999", "WorksiteAddrsID" => "74999" }) From 0127b5fc35548a51fa15ab2e2aee5709de3fcfd4 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:06:34 -0400 Subject: [PATCH 18/36] Move rolodex classification into identity It classifies the whole person (donor, facilitator, ...), not their facilitator affiliation. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/fm_rolodex.rb | 4 ++-- spec/models/fm_rolodex_spec.rb | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index d2cc6f3898..5cf6b5f6a2 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -108,7 +108,7 @@ class FmRolodex < ApplicationRecord FIELD_GROUPS = { "Identity" => %w[NamePrefix FirstName MiddleName LastName Suffix Pronouns Gender Gender2ndPerson isAgency__c - EMail EmailPrimary EmailType EmailNotes BestTimeToCall], + EMail EmailPrimary EmailType EmailNotes Classification BestTimeToCall], "Phones & addresses" => { PHONES => %w[PrimaryPhoneID], ADDRESSES => %w[PrimaryAddrsID WorksiteAddrsID], @@ -117,7 +117,7 @@ class FmRolodex < ApplicationRecord "Organization & affiliations" => { "Organization" => %w[Organization OrgID WebSite Mission PrimaryContact PrimaryContactID], "Job affiliation" => %w[Title Department Dept2ndName], - "Facilitator affiliation" => %w[Classification Status FacilitatorSinceMonth FacilitatorSinceYear + "Facilitator affiliation" => %w[Status FacilitatorSinceMonth FacilitatorSinceYear FacilitatorStatusPending HowTrained TrainingMotivation LifeExperiences Settings BBSLicense] }, diff --git a/spec/models/fm_rolodex_spec.rb b/spec/models/fm_rolodex_spec.rb index 97aab6bf99..c597d63a67 100644 --- a/spec/models/fm_rolodex_spec.rb +++ b/spec/models/fm_rolodex_spec.rb @@ -3,13 +3,13 @@ RSpec.describe FmRolodex do describe "#grouped_data" do it "clusters fields under their group headings in the listed order" do - rolodex = described_class.new(data: { "Comments" => "Met at training", "LastName" => "Archer", "EMail" => "abe@example.com", "FirstName" => "Abe" }) + rolodex = described_class.new(data: { "Comments" => "Met at training", "Classification" => "Donor", "LastName" => "Archer", "EMail" => "abe@example.com", "FirstName" => "Abe" }) expect(rolodex.grouped_data).to eq( - "Identity" => { nil => { "FirstName" => "Abe", "LastName" => "Archer", "EMail" => "abe@example.com" } }, + "Identity" => { nil => { "FirstName" => "Abe", "LastName" => "Archer", "EMail" => "abe@example.com", "Classification" => "Donor" } }, "Notes" => { nil => { "Comments" => "Met at training" } } ) - expect(rolodex.grouped_data.dig("Identity", nil).keys).to eq(%w[FirstName LastName EMail]) + expect(rolodex.grouped_data.dig("Identity", nil).keys).to eq(%w[FirstName LastName EMail Classification]) end it "splits a card into its subcards" do From 1d9e02982383b5ffae3cec65a5a0c976b6899527 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:07:46 -0400 Subject: [PATCH 19/36] Highlight related records the rolodex cards don't already show Phones and addresses now appear in the cards, so the related-records list mostly repeats them; highlighting the rest points at what's only reachable there. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/concerns/fm_archive.rb | 9 +++++++++ app/models/fm_rolodex.rb | 4 ++++ app/views/fm_archives/show.html.erb | 9 +++++++-- spec/requests/fm_archives_spec.rb | 22 ++++++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/app/models/concerns/fm_archive.rb b/app/models/concerns/fm_archive.rb index a2babe3ebf..c094fdea8f 100644 --- a/app/models/concerns/fm_archive.rb +++ b/app/models/concerns/fm_archive.rb @@ -49,6 +49,15 @@ def single_field_group?(label) self.class.flat_field_groups[label]&.one? && records_listed_under(label).none? end + # A related table none of the cards show, so Related records is the only place to find it. + def related_only?(table) + self.class::FIELD_GROUPS.any? && !tables_shown_in_cards.include?(table) + end + + def tables_shown_in_cards + [] + end + # Related records a subcard lists below its fields. def records_listed_under(_label) [] diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index 5cf6b5f6a2..55591bdccb 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -165,6 +165,10 @@ def addresses @addresses ||= contact_records(FmAddress, primary_ids: data.values_at("PrimaryAddrsID", "WorksiteAddrsID")) end + def tables_shown_in_cards + [ FmPhoneNumber.table_name, FmAddress.table_name ] + end + def records_listed_under(label) case label when PHONES then phone_numbers diff --git a/app/views/fm_archives/show.html.erb b/app/views/fm_archives/show.html.erb index df7e360bb4..b1a8f493e3 100644 --- a/app/views/fm_archives/show.html.erb +++ b/app/views/fm_archives/show.html.erb @@ -50,9 +50,14 @@ <% if @backlinks.any? %>
    -

    Related Records

    +

    Related Records

    + <% if @record.class::FIELD_GROUPS.any? %> +

    Highlighted tables aren't shown in the cards above.

    + <% end %> <% @backlinks.each do |label, info| %> - <%= turbo_frame_tag "backlink_#{info[:table]}", class: "block py-3 border-b border-gray-200 last:border-0" do %> + <%= turbo_frame_tag "backlink_#{info[:table]}", + class: class_names("block py-3 border-b border-gray-200 last:border-0", + "-mx-3 px-3 rounded-lg bg-amber-50": @record.related_only?(info[:table])) do %>

    <%= label %> (<%= info[:records].total_entries %>)

    <% info[:records].each do |record| %> diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index 2190a391f4..6e1a614e63 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -88,6 +88,28 @@ expect(response.body).not_to include(fm_archive_path("ORG90999", table: "organizations")) end + it "highlights related records the cards above don't show" do + FmRolodex.create!(fm_id: "00006", data: { "ID" => "00006" }) + FmAddress.create!(fm_id: "74001", data: { "RolodexID" => "00006" }) + FmFunding.create!(fm_id: "FND1", data: { "FunderID" => "00006" }) + + get fm_archive_path("00006", table: "rolodexes") + + expect(response.body).to match(/]*class="[^"]*bg-amber-50[^"]*"[^>]*id="backlink_fm_fundings"|]*id="backlink_fm_fundings"[^>]*class="[^"]*bg-amber-50/) + expect(response.body).not_to match(/]*id="backlink_fm_addresses"[^>]*class="[^"]*bg-amber-50/) + expect(response.body).not_to match(/]*class="[^"]*bg-amber-50[^"]*"[^>]*id="backlink_fm_addresses"/) + end + + it "leaves related records unhighlighted on tables without cards" do + FmProject.create!(fm_id: "P2775") + FmPersonnel.create!(fm_id: "PS1", data: { "ProjectID" => "P2775" }) + + get fm_archive_path("P2775", table: "projects") + + expect(response.body).to include("backlink_fm_personnels") + expect(response.body).not_to include("bg-amber-50") + end + it "leaves the field reference off tables without field groups" do FmProject.create!(fm_id: "P2775") From ee3e34fbcd712970b99504d8c18ea585a95228fe Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:15:28 -0400 Subject: [PATCH 20/36] Lay related records out in columns and mark uncovered tables yellow The stacked list ran long; columns keep it to a few rows. The same yellow marks those tables in the field reference. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../fm_archives/_field_reference.html.erb | 5 +-- app/views/fm_archives/show.html.erb | 36 ++++++++++--------- spec/requests/fm_archives_spec.rb | 12 ++++--- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/app/views/fm_archives/_field_reference.html.erb b/app/views/fm_archives/_field_reference.html.erb index b96cf1f99a..41a5665982 100644 --- a/app/views/fm_archives/_field_reference.html.erb +++ b/app/views/fm_archives/_field_reference.html.erb @@ -33,10 +33,11 @@
-

Records pointing here

+

Records pointing here

+

Tables in yellow aren't shown in the cards above.

    <% model::HAS_MANY.each do |table, config| %> -
  • +
  • "> <%= link_to config[:label], fm_archives_path(table: table.delete_prefix("fm_")), class: "text-primary hover:underline" %> via <%= config[:via] %> diff --git a/app/views/fm_archives/show.html.erb b/app/views/fm_archives/show.html.erb index b1a8f493e3..5c55458004 100644 --- a/app/views/fm_archives/show.html.erb +++ b/app/views/fm_archives/show.html.erb @@ -52,27 +52,29 @@

    Related Records

    <% if @record.class::FIELD_GROUPS.any? %> -

    Highlighted tables aren't shown in the cards above.

    +

    Tables in yellow aren't shown in the cards above.

    <% end %> - <% @backlinks.each do |label, info| %> - <%= turbo_frame_tag "backlink_#{info[:table]}", - class: class_names("block py-3 border-b border-gray-200 last:border-0", - "-mx-3 px-3 rounded-lg bg-amber-50": @record.related_only?(info[:table])) do %> -

    <%= label %> (<%= info[:records].total_entries %>)

    -
    - <% info[:records].each do |record| %> - <%= link_to record.fm_id, fm_archive_path(record.fm_id, table: info[:table].sub("fm_", "")), - class: "text-xs font-mono text-primary hover:underline bg-gray-50 px-2 py-1 rounded", - data: { turbo_frame: "_top" } %> - <% end %> -
    - <% if info[:records].total_pages > 1 %> -
    - <%= tailwind_paginate info[:records], { renderer: FmArchivePaginationRenderer, param_name: info[:page_param], params: { id: @record.fm_id, table: @selected } } %> +
    + <% @backlinks.each do |label, info| %> + <%= turbo_frame_tag "backlink_#{info[:table]}", + class: class_names("block min-w-0 overflow-x-auto rounded-lg border p-3", + @record.related_only?(info[:table]) ? "border-yellow-200 bg-yellow-100" : "border-gray-200") do %> +

    <%= label %> (<%= info[:records].total_entries %>)

    +
    + <% info[:records].each do |record| %> + <%= link_to record.fm_id, fm_archive_path(record.fm_id, table: info[:table].sub("fm_", "")), + class: "text-xs font-mono text-primary hover:underline bg-gray-50 px-2 py-1 rounded", + data: { turbo_frame: "_top" } %> + <% end %>
    + <% if info[:records].total_pages > 1 %> +
    + <%= tailwind_paginate info[:records], { renderer: FmArchivePaginationRenderer, param_name: info[:page_param], params: { id: @record.fm_id, table: @selected } } %> +
    + <% end %> <% end %> <% end %> - <% end %> +
    <% end %> diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index 6e1a614e63..fb87a2a05b 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -95,9 +95,13 @@ get fm_archive_path("00006", table: "rolodexes") - expect(response.body).to match(/]*class="[^"]*bg-amber-50[^"]*"[^>]*id="backlink_fm_fundings"|]*id="backlink_fm_fundings"[^>]*class="[^"]*bg-amber-50/) - expect(response.body).not_to match(/]*id="backlink_fm_addresses"[^>]*class="[^"]*bg-amber-50/) - expect(response.body).not_to match(/]*class="[^"]*bg-amber-50[^"]*"[^>]*id="backlink_fm_addresses"/) + expect(response.body).to match(/]*class="[^"]*bg-yellow-100[^"]*"[^>]*id="backlink_fm_fundings"|]*id="backlink_fm_fundings"[^>]*class="[^"]*bg-yellow-100/) + expect(response.body).not_to match(/]*id="backlink_fm_addresses"[^>]*class="[^"]*bg-yellow-100/) + expect(response.body).not_to match(/]*class="[^"]*bg-yellow-100[^"]*"[^>]*id="backlink_fm_addresses"/) + + reference = response.body.split("Records pointing here").last + expect(reference).to match(%r{
  • \s*]*>Funding}) + expect(reference).not_to match(%r{
  • \s*]*>Addresses}) end it "leaves related records unhighlighted on tables without cards" do @@ -107,7 +111,7 @@ get fm_archive_path("P2775", table: "projects") expect(response.body).to include("backlink_fm_personnels") - expect(response.body).not_to include("bg-amber-50") + expect(response.body).not_to include("bg-yellow-100") end it "leaves the field reference off tables without field groups" do From 75526030967dcb452d01e776c50e11f06c86244f Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:16:53 -0400 Subject: [PATCH 21/36] Cluster a rolodex person's related tables by kind Contact history, affiliations, workshops, events, money, fundraising and the rest now sit together instead of in import order. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/fm_rolodex.rb | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index 55591bdccb..2277d8effb 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -134,27 +134,27 @@ class FmRolodex < ApplicationRecord HAS_MANY = { "fm_addresses" => { via: "RolodexID", label: "Addresses" }, "fm_phone_numbers" => { via: "RolodexID", label: "Phone Numbers" }, - "fm_payments" => { via: "RolodexID", label: "Payments" }, - "fm_activities" => { via: "ID", label: "Activities" }, + "fm_contacts" => { via: "ContacteeID", label: "Contacts" }, "fm_notes" => { via: "RolodexID", label: "Notes" }, + "fm_activities" => { via: "ID", label: "Activities" }, + "fm_form_letters" => { via: "SignatureID", label: "Form Letters (Signed)" }, + "fm_people_to_organizations" => { via: "PersonID", label: "Org Affiliations" }, "fm_personnels" => { via: "PersonID", label: "Personnel" }, + "fm_committee_members" => { via: "MemberID", label: "Committee Memberships" }, + "fm_group_members" => { via: "ContactID", label: "Group Memberships" }, "fm_workshop_logs" => { via: "LeaderID", label: "Workshop Logs" }, + "fm_workshops" => { via: "AuthorID", label: "Workshops (Authored)" }, "fm_participants" => { via: "ParticipantID", label: "Participations" }, + "fm_volunteers" => { via: "VRecID", label: "Volunteer Records" }, + "fm_payments" => { via: "RolodexID", label: "Payments" }, + "fm_invoices" => { via: "RolodexID", label: "Invoices" }, "fm_fundings" => { via: "FunderID", label: "Funding" }, "fm_program_sponsorships" => { via: "FunderID", label: "Program Sponsorships" }, - "fm_ancillary_data" => { via: "RolodexID", label: "Ancillary Data" }, - "fm_art_registries" => { via: "ArtistID", label: "Art Registries (Artist)" }, - "fm_committee_members" => { via: "MemberID", label: "Committee Memberships" }, - "fm_contacts" => { via: "ContacteeID", label: "Contacts" }, + "fm_solicitations" => { via: "DonorID", label: "Solicitations" }, "fm_donor_developers" => { via: "RolodexID", label: "Donor Developments" }, - "fm_form_letters" => { via: "SignatureID", label: "Form Letters (Signed)" }, - "fm_group_members" => { via: "ContactID", label: "Group Memberships" }, - "fm_invoices" => { via: "RolodexID", label: "Invoices" }, - "fm_people_to_organizations" => { via: "PersonID", label: "Org Affiliations" }, "fm_quotations" => { via: "RolodexID", label: "Quotations" }, - "fm_solicitations" => { via: "DonorID", label: "Solicitations" }, - "fm_volunteers" => { via: "VRecID", label: "Volunteer Records" }, - "fm_workshops" => { via: "AuthorID", label: "Workshops (Authored)" } + "fm_art_registries" => { via: "ArtistID", label: "Art Registries (Artist)" }, + "fm_ancillary_data" => { via: "RolodexID", label: "Ancillary Data" } }.freeze def phone_numbers From fc082f6ca58839eceeb0231b81b4beb66c60cbad Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:19:09 -0400 Subject: [PATCH 22/36] Mark related archive tables that have portal counterparts in orange Those are the ones worth reconciling against portal records, so they stand apart from tables only the archive holds. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/helpers/filemaker_helper.rb | 7 +++++++ app/models/concerns/fm_archive.rb | 6 ++++++ app/models/fm_rolodex.rb | 10 ++++++++++ app/views/fm_archives/_field_reference.html.erb | 4 ++-- app/views/fm_archives/_related_legend.html.erb | 4 ++++ app/views/fm_archives/show.html.erb | 11 +++++++---- spec/helpers/filemaker_helper_spec.rb | 16 ++++++++++++++++ spec/requests/fm_archives_spec.rb | 12 ++++++++++++ 8 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 app/views/fm_archives/_related_legend.html.erb diff --git a/app/helpers/filemaker_helper.rb b/app/helpers/filemaker_helper.rb index 82f7c5decd..3a6bd64554 100644 --- a/app/helpers/filemaker_helper.rb +++ b/app/helpers/filemaker_helper.rb @@ -50,6 +50,13 @@ def fm_archive_reference_link(fm_id, table) link_to fm_id, fm_archive_path(fm_id, table: key), class: "text-primary hover:underline" end + # Colors a related table the cards leave out: orange when the portal keeps its own such records, else yellow. + def fm_related_table_highlight(record, table) + return unless record.related_only?(table) + + record.portal_counterpart(table) ? "border-orange-200 bg-orange-100" : "border-yellow-200 bg-yellow-100" + end + # A code no archive table holds renders struck through, flagging a likely typo — # or is dropped with `linked_only`, for nav buttons that must all lead somewhere. def filemaker_archive_links(codes, archives:, link_class: nil, label: nil, linked_only: false) diff --git a/app/models/concerns/fm_archive.rb b/app/models/concerns/fm_archive.rb index c094fdea8f..9d30faa6b4 100644 --- a/app/models/concerns/fm_archive.rb +++ b/app/models/concerns/fm_archive.rb @@ -5,6 +5,7 @@ module FmArchive OTHER_FIELDS = "Other fields" LISTED_FIELDS = [].freeze TALL_GROUPS = [].freeze + PORTAL_COUNTERPARTS = {}.freeze included do before_validation :set_defaults @@ -58,6 +59,11 @@ def tables_shown_in_cards [] end + # The portal model holding its own records of what a related archive table holds. + def portal_counterpart(table) + self.class::PORTAL_COUNTERPARTS[table]&.constantize + end + # Related records a subcard lists below its fields. def records_listed_under(_label) [] diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index 2277d8effb..a53a93c478 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -105,6 +105,16 @@ class FmRolodex < ApplicationRecord ADDRESSES = "Addresses" LISTED_FIELDS = %w[PrimaryPhoneID PrimaryAddrsID WorksiteAddrsID].freeze TALL_GROUPS = [ "Organization & affiliations" ].freeze + PORTAL_COUNTERPARTS = { + "fm_people_to_organizations" => "Affiliation", + "fm_personnels" => "Affiliation", + "fm_workshop_logs" => "WorkshopLog", + "fm_workshops" => "Workshop", + "fm_participants" => "EventRegistration", + "fm_payments" => "Payment", + "fm_invoices" => "Invoice", + "fm_quotations" => "Quote" + }.freeze FIELD_GROUPS = { "Identity" => %w[NamePrefix FirstName MiddleName LastName Suffix Pronouns Gender Gender2ndPerson isAgency__c diff --git a/app/views/fm_archives/_field_reference.html.erb b/app/views/fm_archives/_field_reference.html.erb index 41a5665982..8130825292 100644 --- a/app/views/fm_archives/_field_reference.html.erb +++ b/app/views/fm_archives/_field_reference.html.erb @@ -34,10 +34,10 @@

Records pointing here

-

Tables in yellow aren't shown in the cards above.

+ <%= render "related_legend" %>
    <% model::HAS_MANY.each do |table, config| %> -
  • "> +
  • "> <%= link_to config[:label], fm_archives_path(table: table.delete_prefix("fm_")), class: "text-primary hover:underline" %> via <%= config[:via] %> diff --git a/app/views/fm_archives/_related_legend.html.erb b/app/views/fm_archives/_related_legend.html.erb new file mode 100644 index 0000000000..6dd1acf9aa --- /dev/null +++ b/app/views/fm_archives/_related_legend.html.erb @@ -0,0 +1,4 @@ +

    + Not shown in the cards above + Not shown above, and the portal has its own records of this kind +

    diff --git a/app/views/fm_archives/show.html.erb b/app/views/fm_archives/show.html.erb index 5c55458004..0a31725e08 100644 --- a/app/views/fm_archives/show.html.erb +++ b/app/views/fm_archives/show.html.erb @@ -52,15 +52,18 @@

    Related Records

    <% if @record.class::FIELD_GROUPS.any? %> -

    Tables in yellow aren't shown in the cards above.

    + <%= render "related_legend" %> <% end %>
    <% @backlinks.each do |label, info| %> <%= turbo_frame_tag "backlink_#{info[:table]}", class: class_names("block min-w-0 overflow-x-auto rounded-lg border p-3", - @record.related_only?(info[:table]) ? "border-yellow-200 bg-yellow-100" : "border-gray-200") do %> -

    <%= label %> (<%= info[:records].total_entries %>)

    -
    + fm_related_table_highlight(@record, info[:table]) || "border-gray-200") do %> +

    <%= label %> (<%= info[:records].total_entries %>)

    + <% if (counterpart = @record.portal_counterpart(info[:table])) %> +

    In the portal as <%= counterpart.model_name.human.pluralize.downcase %>

    + <% end %> +
    <% info[:records].each do |record| %> <%= link_to record.fm_id, fm_archive_path(record.fm_id, table: info[:table].sub("fm_", "")), class: "text-xs font-mono text-primary hover:underline bg-gray-50 px-2 py-1 rounded", diff --git a/spec/helpers/filemaker_helper_spec.rb b/spec/helpers/filemaker_helper_spec.rb index 65155f79c9..27c187e127 100644 --- a/spec/helpers/filemaker_helper_spec.rb +++ b/spec/helpers/filemaker_helper_spec.rb @@ -100,6 +100,22 @@ end end + describe "#fm_related_table_highlight" do + let(:rolodex) { FmRolodex.new } + + it "colors a table the portal has its own records for orange" do + expect(helper.fm_related_table_highlight(rolodex, "fm_workshop_logs")).to include("bg-orange-100") + end + + it "colors a table with no portal counterpart yellow" do + expect(helper.fm_related_table_highlight(rolodex, "fm_fundings")).to include("bg-yellow-100") + end + + it "leaves a table the cards already show uncolored" do + expect(helper.fm_related_table_highlight(rolodex, "fm_addresses")).to be_nil + end + end + describe "#filemaker_code_hint" do before { FmRolodex.create!(fm_id: "00006") } diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index fb87a2a05b..cfcdd07771 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -104,6 +104,18 @@ expect(reference).not_to match(%r{
  • \s*]*>Addresses}) end + it "marks related tables the portal has its own records for in orange, naming the counterpart" do + FmRolodex.create!(fm_id: "00006", data: { "ID" => "00006" }) + FmWorkshopLog.create!(fm_id: "76001", data: { "LeaderID" => "00006" }) + FmFunding.create!(fm_id: "FND1", data: { "FunderID" => "00006" }) + + get fm_archive_path("00006", table: "rolodexes") + + logs = response.body[/]*id="backlink_fm_workshop_logs".*?<\/turbo-frame>/m] + expect(logs).to include("bg-orange-100", "In the portal as workshop logs") + expect(response.body[/]*id="backlink_fm_fundings".*?<\/turbo-frame>/m]).to include("bg-yellow-100") + end + it "leaves related records unhighlighted on tables without cards" do FmProject.create!(fm_id: "P2775") FmPersonnel.create!(fm_id: "PS1", data: { "ProjectID" => "P2775" }) From 1aede400e17aa593e13e9c4aa972b31c5853a710 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:21:10 -0400 Subject: [PATCH 23/36] Drop the yellow related-records highlight; orange marks portal counterparts Orange alone answers the useful question (does the portal keep its own records of this kind?), so phones and addresses join it. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/helpers/filemaker_helper.rb | 6 ++-- app/models/concerns/fm_archive.rb | 9 ------ app/models/fm_rolodex.rb | 6 ++-- .../fm_archives/_related_legend.html.erb | 5 ++-- app/views/fm_archives/show.html.erb | 2 +- spec/helpers/filemaker_helper_spec.rb | 8 ++---- spec/requests/fm_archives_spec.rb | 28 ++++++------------- 7 files changed, 18 insertions(+), 46 deletions(-) diff --git a/app/helpers/filemaker_helper.rb b/app/helpers/filemaker_helper.rb index 3a6bd64554..9ab55cf1f9 100644 --- a/app/helpers/filemaker_helper.rb +++ b/app/helpers/filemaker_helper.rb @@ -50,11 +50,9 @@ def fm_archive_reference_link(fm_id, table) link_to fm_id, fm_archive_path(fm_id, table: key), class: "text-primary hover:underline" end - # Colors a related table the cards leave out: orange when the portal keeps its own such records, else yellow. + # Colors a related table orange when the portal keeps its own records of the same kind. def fm_related_table_highlight(record, table) - return unless record.related_only?(table) - - record.portal_counterpart(table) ? "border-orange-200 bg-orange-100" : "border-yellow-200 bg-yellow-100" + "border-orange-200 bg-orange-100" if record.portal_counterpart(table) end # A code no archive table holds renders struck through, flagging a likely typo — diff --git a/app/models/concerns/fm_archive.rb b/app/models/concerns/fm_archive.rb index 9d30faa6b4..417e7c6b58 100644 --- a/app/models/concerns/fm_archive.rb +++ b/app/models/concerns/fm_archive.rb @@ -50,15 +50,6 @@ def single_field_group?(label) self.class.flat_field_groups[label]&.one? && records_listed_under(label).none? end - # A related table none of the cards show, so Related records is the only place to find it. - def related_only?(table) - self.class::FIELD_GROUPS.any? && !tables_shown_in_cards.include?(table) - end - - def tables_shown_in_cards - [] - end - # The portal model holding its own records of what a related archive table holds. def portal_counterpart(table) self.class::PORTAL_COUNTERPARTS[table]&.constantize diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index a53a93c478..ec20448bf7 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -106,6 +106,8 @@ class FmRolodex < ApplicationRecord LISTED_FIELDS = %w[PrimaryPhoneID PrimaryAddrsID WorksiteAddrsID].freeze TALL_GROUPS = [ "Organization & affiliations" ].freeze PORTAL_COUNTERPARTS = { + "fm_addresses" => "Address", + "fm_phone_numbers" => "ContactMethod", "fm_people_to_organizations" => "Affiliation", "fm_personnels" => "Affiliation", "fm_workshop_logs" => "WorkshopLog", @@ -175,10 +177,6 @@ def addresses @addresses ||= contact_records(FmAddress, primary_ids: data.values_at("PrimaryAddrsID", "WorksiteAddrsID")) end - def tables_shown_in_cards - [ FmPhoneNumber.table_name, FmAddress.table_name ] - end - def records_listed_under(label) case label when PHONES then phone_numbers diff --git a/app/views/fm_archives/_related_legend.html.erb b/app/views/fm_archives/_related_legend.html.erb index 6dd1acf9aa..dc0382e3f2 100644 --- a/app/views/fm_archives/_related_legend.html.erb +++ b/app/views/fm_archives/_related_legend.html.erb @@ -1,4 +1,3 @@ -

    - Not shown in the cards above - Not shown above, and the portal has its own records of this kind +

    + The portal has its own records of this kind

    diff --git a/app/views/fm_archives/show.html.erb b/app/views/fm_archives/show.html.erb index 0a31725e08..d87e466e58 100644 --- a/app/views/fm_archives/show.html.erb +++ b/app/views/fm_archives/show.html.erb @@ -51,7 +51,7 @@ <% if @backlinks.any? %>

    Related Records

    - <% if @record.class::FIELD_GROUPS.any? %> + <% if @record.class::PORTAL_COUNTERPARTS.any? %> <%= render "related_legend" %> <% end %>
    diff --git a/spec/helpers/filemaker_helper_spec.rb b/spec/helpers/filemaker_helper_spec.rb index 27c187e127..a5ab1617fc 100644 --- a/spec/helpers/filemaker_helper_spec.rb +++ b/spec/helpers/filemaker_helper_spec.rb @@ -107,12 +107,8 @@ expect(helper.fm_related_table_highlight(rolodex, "fm_workshop_logs")).to include("bg-orange-100") end - it "colors a table with no portal counterpart yellow" do - expect(helper.fm_related_table_highlight(rolodex, "fm_fundings")).to include("bg-yellow-100") - end - - it "leaves a table the cards already show uncolored" do - expect(helper.fm_related_table_highlight(rolodex, "fm_addresses")).to be_nil + it "leaves a table with no portal counterpart uncolored" do + expect(helper.fm_related_table_highlight(rolodex, "fm_fundings")).to be_nil end end diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index cfcdd07771..90a7b33996 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -88,32 +88,22 @@ expect(response.body).not_to include(fm_archive_path("ORG90999", table: "organizations")) end - it "highlights related records the cards above don't show" do + it "marks related tables the portal has its own records for in orange, naming the counterpart" do FmRolodex.create!(fm_id: "00006", data: { "ID" => "00006" }) + FmWorkshopLog.create!(fm_id: "76001", data: { "LeaderID" => "00006" }) FmAddress.create!(fm_id: "74001", data: { "RolodexID" => "00006" }) FmFunding.create!(fm_id: "FND1", data: { "FunderID" => "00006" }) get fm_archive_path("00006", table: "rolodexes") - expect(response.body).to match(/]*class="[^"]*bg-yellow-100[^"]*"[^>]*id="backlink_fm_fundings"|]*id="backlink_fm_fundings"[^>]*class="[^"]*bg-yellow-100/) - expect(response.body).not_to match(/]*id="backlink_fm_addresses"[^>]*class="[^"]*bg-yellow-100/) - expect(response.body).not_to match(/]*class="[^"]*bg-yellow-100[^"]*"[^>]*id="backlink_fm_addresses"/) + frame = ->(table) { response.body[/]*id="backlink_#{table}".*?<\/turbo-frame>/m] } + expect(frame.call("fm_workshop_logs")).to include("bg-orange-100", "In the portal as workshop logs") + expect(frame.call("fm_addresses")).to include("bg-orange-100", "In the portal as addresses") + expect(frame.call("fm_fundings")).not_to include("bg-orange-100", "In the portal") reference = response.body.split("Records pointing here").last - expect(reference).to match(%r{
  • \s*]*>Funding}) - expect(reference).not_to match(%r{
  • \s*]*>Addresses}) - end - - it "marks related tables the portal has its own records for in orange, naming the counterpart" do - FmRolodex.create!(fm_id: "00006", data: { "ID" => "00006" }) - FmWorkshopLog.create!(fm_id: "76001", data: { "LeaderID" => "00006" }) - FmFunding.create!(fm_id: "FND1", data: { "FunderID" => "00006" }) - - get fm_archive_path("00006", table: "rolodexes") - - logs = response.body[/]*id="backlink_fm_workshop_logs".*?<\/turbo-frame>/m] - expect(logs).to include("bg-orange-100", "In the portal as workshop logs") - expect(response.body[/]*id="backlink_fm_fundings".*?<\/turbo-frame>/m]).to include("bg-yellow-100") + expect(reference).to match(%r{
  • \s*]*>Workshop Logs}) + expect(reference).not_to match(%r{
  • \s*]*>Funding}) end it "leaves related records unhighlighted on tables without cards" do @@ -123,7 +113,7 @@ get fm_archive_path("P2775", table: "projects") expect(response.body).to include("backlink_fm_personnels") - expect(response.body).not_to include("bg-yellow-100") + expect(response.body).not_to include("bg-orange-100") end it "leaves the field reference off tables without field groups" do From ad5194a0cb2250f59644de8f3677f6c1c80a26d6 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:22:48 -0400 Subject: [PATCH 24/36] Name a related table's portal counterpart when it's called something else Co-Authored-By: Claude Opus 5.5 (1M context) --- app/helpers/filemaker_helper.rb | 6 ++++++ app/views/fm_archives/_field_reference.html.erb | 3 +++ spec/helpers/filemaker_helper_spec.rb | 17 +++++++++++++++++ spec/requests/fm_archives_spec.rb | 2 ++ 4 files changed, 28 insertions(+) diff --git a/app/helpers/filemaker_helper.rb b/app/helpers/filemaker_helper.rb index 9ab55cf1f9..73f51d5d02 100644 --- a/app/helpers/filemaker_helper.rb +++ b/app/helpers/filemaker_helper.rb @@ -50,6 +50,12 @@ def fm_archive_reference_link(fm_id, table) link_to fm_id, fm_archive_path(fm_id, table: key), class: "text-primary hover:underline" end + # The portal's name for a related table's counterpart, when it isn't the archive table's own name. + def fm_portal_counterpart_name(record, table, label) + name = record.portal_counterpart(table)&.model_name&.human&.pluralize + name unless name.nil? || name.casecmp?(label.sub(/\s*\(.*\)\z/, "")) + end + # Colors a related table orange when the portal keeps its own records of the same kind. def fm_related_table_highlight(record, table) "border-orange-200 bg-orange-100" if record.portal_counterpart(table) diff --git a/app/views/fm_archives/_field_reference.html.erb b/app/views/fm_archives/_field_reference.html.erb index 8130825292..75e13c3675 100644 --- a/app/views/fm_archives/_field_reference.html.erb +++ b/app/views/fm_archives/_field_reference.html.erb @@ -39,6 +39,9 @@ <% model::HAS_MANY.each do |table, config| %>
  • "> <%= link_to config[:label], fm_archives_path(table: table.delete_prefix("fm_")), class: "text-primary hover:underline" %> + <% if (portal_name = fm_portal_counterpart_name(record, table, config[:label])) %> + (<%= portal_name %>) + <% end %> via <%= config[:via] %>
  • diff --git a/spec/helpers/filemaker_helper_spec.rb b/spec/helpers/filemaker_helper_spec.rb index a5ab1617fc..178dddd974 100644 --- a/spec/helpers/filemaker_helper_spec.rb +++ b/spec/helpers/filemaker_helper_spec.rb @@ -100,6 +100,23 @@ end end + describe "#fm_portal_counterpart_name" do + let(:rolodex) { FmRolodex.new } + + it "names the portal counterpart when it differs from the archive's name" do + expect(helper.fm_portal_counterpart_name(rolodex, "fm_personnels", "Personnel")).to eq("Affiliations") + end + + it "skips a counterpart sharing the archive's name, ignoring case and a parenthetical" do + expect(helper.fm_portal_counterpart_name(rolodex, "fm_workshop_logs", "Workshop Logs")).to be_nil + expect(helper.fm_portal_counterpart_name(rolodex, "fm_workshops", "Workshops (Authored)")).to be_nil + end + + it "skips a table without a portal counterpart" do + expect(helper.fm_portal_counterpart_name(rolodex, "fm_fundings", "Funding")).to be_nil + end + end + describe "#fm_related_table_highlight" do let(:rolodex) { FmRolodex.new } diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index 90a7b33996..ada91b6f14 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -103,6 +103,8 @@ reference = response.body.split("Records pointing here").last expect(reference).to match(%r{
  • \s*]*>Workshop Logs}) + expect(reference).to match(%r{>Personnel\s*]*>\(Affiliations\)}) + expect(reference).not_to include("(Workshop logs)") expect(reference).not_to match(%r{
  • \s*]*>Funding}) end From b670b4963e368bbb8e34999a4fb860407609dfdf Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:24:31 -0400 Subject: [PATCH 25/36] Map a rolodex person's FileMaker funding to portal scholarships A funding row tied to a person is their scholarship request and award. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/fm_rolodex.rb | 1 + spec/helpers/filemaker_helper_spec.rb | 4 ++-- spec/requests/fm_archives_spec.rb | 7 +++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index ec20448bf7..0bc2cc72b9 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -114,6 +114,7 @@ class FmRolodex < ApplicationRecord "fm_workshops" => "Workshop", "fm_participants" => "EventRegistration", "fm_payments" => "Payment", + "fm_fundings" => "Scholarship", "fm_invoices" => "Invoice", "fm_quotations" => "Quote" }.freeze diff --git a/spec/helpers/filemaker_helper_spec.rb b/spec/helpers/filemaker_helper_spec.rb index 178dddd974..a4a5ea5cf4 100644 --- a/spec/helpers/filemaker_helper_spec.rb +++ b/spec/helpers/filemaker_helper_spec.rb @@ -113,7 +113,7 @@ end it "skips a table without a portal counterpart" do - expect(helper.fm_portal_counterpart_name(rolodex, "fm_fundings", "Funding")).to be_nil + expect(helper.fm_portal_counterpart_name(rolodex, "fm_notes", "Notes")).to be_nil end end @@ -125,7 +125,7 @@ end it "leaves a table with no portal counterpart uncolored" do - expect(helper.fm_related_table_highlight(rolodex, "fm_fundings")).to be_nil + expect(helper.fm_related_table_highlight(rolodex, "fm_notes")).to be_nil end end diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index ada91b6f14..4024639aa1 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -93,19 +93,22 @@ FmWorkshopLog.create!(fm_id: "76001", data: { "LeaderID" => "00006" }) FmAddress.create!(fm_id: "74001", data: { "RolodexID" => "00006" }) FmFunding.create!(fm_id: "FND1", data: { "FunderID" => "00006" }) + FmNote.create!(fm_id: "N1", data: { "RolodexID" => "00006" }) get fm_archive_path("00006", table: "rolodexes") frame = ->(table) { response.body[/]*id="backlink_#{table}".*?<\/turbo-frame>/m] } expect(frame.call("fm_workshop_logs")).to include("bg-orange-100", "In the portal as workshop logs") expect(frame.call("fm_addresses")).to include("bg-orange-100", "In the portal as addresses") - expect(frame.call("fm_fundings")).not_to include("bg-orange-100", "In the portal") + expect(frame.call("fm_fundings")).to include("bg-orange-100", "In the portal as scholarships") + expect(frame.call("fm_notes")).not_to include("bg-orange-100", "In the portal") reference = response.body.split("Records pointing here").last expect(reference).to match(%r{
  • \s*]*>Workshop Logs}) expect(reference).to match(%r{>Personnel\s*]*>\(Affiliations\)}) expect(reference).not_to include("(Workshop logs)") - expect(reference).not_to match(%r{
  • \s*]*>Funding}) + expect(reference).to match(%r{>Funding\s*]*>\(Scholarships\)}) + expect(reference).not_to match(%r{
  • \s*]*>Notes}) end it "leaves related records unhighlighted on tables without cards" do From 4a81b7b19dced15458e10aac6344b472108aa823 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:25:42 -0400 Subject: [PATCH 26/36] Label authored workshops without parentheses Co-Authored-By: Claude Opus 5.5 (1M context) --- app/helpers/filemaker_helper.rb | 4 ++-- app/models/fm_rolodex.rb | 2 +- spec/helpers/filemaker_helper_spec.rb | 4 ++-- spec/requests/fm_archives_spec.rb | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/helpers/filemaker_helper.rb b/app/helpers/filemaker_helper.rb index 73f51d5d02..5e94803036 100644 --- a/app/helpers/filemaker_helper.rb +++ b/app/helpers/filemaker_helper.rb @@ -50,10 +50,10 @@ def fm_archive_reference_link(fm_id, table) link_to fm_id, fm_archive_path(fm_id, table: key), class: "text-primary hover:underline" end - # The portal's name for a related table's counterpart, when it isn't the archive table's own name. + # The portal's name for a related table's counterpart, when the archive's label doesn't already lead with it. def fm_portal_counterpart_name(record, table, label) name = record.portal_counterpart(table)&.model_name&.human&.pluralize - name unless name.nil? || name.casecmp?(label.sub(/\s*\(.*\)\z/, "")) + name unless name.nil? || label.downcase.start_with?(name.downcase) end # Colors a related table orange when the portal keeps its own records of the same kind. diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index 0bc2cc72b9..350db683b3 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -156,7 +156,7 @@ class FmRolodex < ApplicationRecord "fm_committee_members" => { via: "MemberID", label: "Committee Memberships" }, "fm_group_members" => { via: "ContactID", label: "Group Memberships" }, "fm_workshop_logs" => { via: "LeaderID", label: "Workshop Logs" }, - "fm_workshops" => { via: "AuthorID", label: "Workshops (Authored)" }, + "fm_workshops" => { via: "AuthorID", label: "Workshops Authored" }, "fm_participants" => { via: "ParticipantID", label: "Participations" }, "fm_volunteers" => { via: "VRecID", label: "Volunteer Records" }, "fm_payments" => { via: "RolodexID", label: "Payments" }, diff --git a/spec/helpers/filemaker_helper_spec.rb b/spec/helpers/filemaker_helper_spec.rb index a4a5ea5cf4..7867870add 100644 --- a/spec/helpers/filemaker_helper_spec.rb +++ b/spec/helpers/filemaker_helper_spec.rb @@ -107,9 +107,9 @@ expect(helper.fm_portal_counterpart_name(rolodex, "fm_personnels", "Personnel")).to eq("Affiliations") end - it "skips a counterpart sharing the archive's name, ignoring case and a parenthetical" do + it "skips a counterpart the archive's label already leads with, ignoring case" do expect(helper.fm_portal_counterpart_name(rolodex, "fm_workshop_logs", "Workshop Logs")).to be_nil - expect(helper.fm_portal_counterpart_name(rolodex, "fm_workshops", "Workshops (Authored)")).to be_nil + expect(helper.fm_portal_counterpart_name(rolodex, "fm_workshops", "Workshops Authored")).to be_nil end it "skips a table without a portal counterpart" do diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index 4024639aa1..c5cdfbcf50 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -61,7 +61,7 @@ get fm_archive_path("00006", table: "rolodexes") - expect(response.body).to match(/Record keeping.*Available fields & associations.*FacilitatorSinceYear.*OrgID.*Organizations.*Workshops \(Authored\).*AuthorID/m) + expect(response.body).to match(/Record keeping.*Available fields & associations.*FacilitatorSinceYear.*OrgID.*Organizations.*Workshops Authored.*AuthorID/m) end it "hides blank fields from the cards but keeps them, grayed, in the field reference" do From 50f4f1e4473bf347ab9718c506ba33c180aad071 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:27:11 -0400 Subject: [PATCH 27/36] Group rolodex classification and status beside the affiliations The portal derives both from a person's affiliations, so they read best next to them. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/fm_rolodex.rb | 5 +++-- spec/models/fm_rolodex_spec.rb | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index 350db683b3..eea2f94aad 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -121,16 +121,17 @@ class FmRolodex < ApplicationRecord FIELD_GROUPS = { "Identity" => %w[NamePrefix FirstName MiddleName LastName Suffix Pronouns Gender Gender2ndPerson isAgency__c - EMail EmailPrimary EmailType EmailNotes Classification BestTimeToCall], + EMail EmailPrimary EmailType EmailNotes BestTimeToCall], "Phones & addresses" => { PHONES => %w[PrimaryPhoneID], ADDRESSES => %w[PrimaryAddrsID WorksiteAddrsID], "Mailing list" => %w[MailingList] }, "Organization & affiliations" => { + "Classification & status" => %w[Classification Status], "Organization" => %w[Organization OrgID WebSite Mission PrimaryContact PrimaryContactID], "Job affiliation" => %w[Title Department Dept2ndName], - "Facilitator affiliation" => %w[Status FacilitatorSinceMonth FacilitatorSinceYear + "Facilitator affiliation" => %w[FacilitatorSinceMonth FacilitatorSinceYear FacilitatorStatusPending HowTrained TrainingMotivation LifeExperiences Settings BBSLicense] }, diff --git a/spec/models/fm_rolodex_spec.rb b/spec/models/fm_rolodex_spec.rb index c597d63a67..f3f3fcdc7e 100644 --- a/spec/models/fm_rolodex_spec.rb +++ b/spec/models/fm_rolodex_spec.rb @@ -6,10 +6,11 @@ rolodex = described_class.new(data: { "Comments" => "Met at training", "Classification" => "Donor", "LastName" => "Archer", "EMail" => "abe@example.com", "FirstName" => "Abe" }) expect(rolodex.grouped_data).to eq( - "Identity" => { nil => { "FirstName" => "Abe", "LastName" => "Archer", "EMail" => "abe@example.com", "Classification" => "Donor" } }, + "Identity" => { nil => { "FirstName" => "Abe", "LastName" => "Archer", "EMail" => "abe@example.com" } }, + "Organization & affiliations" => { "Classification & status" => { "Classification" => "Donor" } }, "Notes" => { nil => { "Comments" => "Met at training" } } ) - expect(rolodex.grouped_data.dig("Identity", nil).keys).to eq(%w[FirstName LastName EMail Classification]) + expect(rolodex.grouped_data.dig("Identity", nil).keys).to eq(%w[FirstName LastName EMail]) end it "splits a card into its subcards" do @@ -17,9 +18,9 @@ expect(rolodex.grouped_data).to eq( "Organization & affiliations" => { + "Classification & status" => { "Status" => "Active" }, "Organization" => { "Organization" => "Shelter" }, - "Job affiliation" => { "Title" => "Director" }, - "Facilitator affiliation" => { "Status" => "Active" } + "Job affiliation" => { "Title" => "Director" } } ) end From b2a8105500622237342f267c562196842638ef31 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:29:04 -0400 Subject: [PATCH 28/36] List a rolodex person's job and facilitator affiliations in their subcards Their organization links lived only as ID chips in related records. Activities map to portal communications, so portal counterparts are now named by what the portal UI calls them. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../fm_people_to_organization_decorator.rb | 10 +++++ app/decorators/fm_personnel_decorator.rb | 19 +++++++++ app/decorators/fm_rolodex_decorator.rb | 2 +- app/helpers/filemaker_helper.rb | 2 +- app/models/concerns/fm_archive.rb | 4 +- app/models/fm_rolodex.rb | 42 ++++++++++++------- ...ords.html.erb => _listed_records.html.erb} | 0 app/views/fm_archives/show.html.erb | 4 +- ...m_people_to_organization_decorator_spec.rb | 16 +++++++ .../decorators/fm_personnel_decorator_spec.rb | 17 ++++++++ spec/models/fm_rolodex_spec.rb | 25 +++++++++++ spec/requests/fm_archives_spec.rb | 14 +++++++ 12 files changed, 134 insertions(+), 21 deletions(-) create mode 100644 app/decorators/fm_people_to_organization_decorator.rb create mode 100644 app/decorators/fm_personnel_decorator.rb rename app/views/fm_archives/{_contact_records.html.erb => _listed_records.html.erb} (100%) create mode 100644 spec/decorators/fm_people_to_organization_decorator_spec.rb create mode 100644 spec/decorators/fm_personnel_decorator_spec.rb diff --git a/app/decorators/fm_people_to_organization_decorator.rb b/app/decorators/fm_people_to_organization_decorator.rb new file mode 100644 index 0000000000..c27e6237e8 --- /dev/null +++ b/app/decorators/fm_people_to_organization_decorator.rb @@ -0,0 +1,10 @@ +class FmPeopleToOrganizationDecorator < ApplicationDecorator + def kind + data["Title"].presence || "Affiliation" + end + + def summary + org_id = data["OrgID"] + FmOrganization.find_by(fm_id: org_id)&.data&.dig("OrgName").presence || org_id + end +end diff --git a/app/decorators/fm_personnel_decorator.rb b/app/decorators/fm_personnel_decorator.rb new file mode 100644 index 0000000000..769218c1e1 --- /dev/null +++ b/app/decorators/fm_personnel_decorator.rb @@ -0,0 +1,19 @@ +class FmPersonnelDecorator < ApplicationDecorator + def kind + data["Role"].presence || "Personnel" + end + + def summary + [ program, *data.values_at("Status", "AgreementStatus") ].compact_blank.join(" · ") + end + + private + + def program + project = FmProject.find_by(fm_id: data["ProjectID"]) + return data["ProjectID"] unless project + + organization = FmOrganization.find_by(fm_id: project.data["OrgID"])&.data&.dig("OrgName") + [ project.data["ProjectName"].presence || project.fm_id, organization ].compact_blank.join(" at ") + end +end diff --git a/app/decorators/fm_rolodex_decorator.rb b/app/decorators/fm_rolodex_decorator.rb index fb3d3c5d8f..62365d52bd 100644 --- a/app/decorators/fm_rolodex_decorator.rb +++ b/app/decorators/fm_rolodex_decorator.rb @@ -5,6 +5,6 @@ class FmRolodexDecorator < ApplicationDecorator }.freeze def contact_tags(record) - CONTACT_TAGS.fetch(record.class).select { |_, key| data[key] == record.fm_id }.keys + CONTACT_TAGS.fetch(record.class, {}).select { |_, key| data[key] == record.fm_id }.keys end end diff --git a/app/helpers/filemaker_helper.rb b/app/helpers/filemaker_helper.rb index 5e94803036..afbdfe495f 100644 --- a/app/helpers/filemaker_helper.rb +++ b/app/helpers/filemaker_helper.rb @@ -52,7 +52,7 @@ def fm_archive_reference_link(fm_id, table) # The portal's name for a related table's counterpart, when the archive's label doesn't already lead with it. def fm_portal_counterpart_name(record, table, label) - name = record.portal_counterpart(table)&.model_name&.human&.pluralize + name = record.portal_counterpart(table) name unless name.nil? || label.downcase.start_with?(name.downcase) end diff --git a/app/models/concerns/fm_archive.rb b/app/models/concerns/fm_archive.rb index 417e7c6b58..3ada69eb3d 100644 --- a/app/models/concerns/fm_archive.rb +++ b/app/models/concerns/fm_archive.rb @@ -50,9 +50,9 @@ def single_field_group?(label) self.class.flat_field_groups[label]&.one? && records_listed_under(label).none? end - # The portal model holding its own records of what a related archive table holds. + # What the portal calls its own records of the kind a related archive table holds. def portal_counterpart(table) - self.class::PORTAL_COUNTERPARTS[table]&.constantize + self.class::PORTAL_COUNTERPARTS[table] end # Related records a subcard lists below its fields. diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index eea2f94aad..5007348284 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -103,20 +103,23 @@ class FmRolodex < ApplicationRecord PHONES = "Phones" ADDRESSES = "Addresses" + JOB_AFFILIATION = "Job affiliation" + FACILITATOR_AFFILIATION = "Facilitator affiliation" LISTED_FIELDS = %w[PrimaryPhoneID PrimaryAddrsID WorksiteAddrsID].freeze TALL_GROUPS = [ "Organization & affiliations" ].freeze PORTAL_COUNTERPARTS = { - "fm_addresses" => "Address", - "fm_phone_numbers" => "ContactMethod", - "fm_people_to_organizations" => "Affiliation", - "fm_personnels" => "Affiliation", - "fm_workshop_logs" => "WorkshopLog", - "fm_workshops" => "Workshop", - "fm_participants" => "EventRegistration", - "fm_payments" => "Payment", - "fm_fundings" => "Scholarship", - "fm_invoices" => "Invoice", - "fm_quotations" => "Quote" + "fm_addresses" => "Addresses", + "fm_phone_numbers" => "Contact methods", + "fm_activities" => "Communications", + "fm_people_to_organizations" => "Affiliations", + "fm_personnels" => "Affiliations", + "fm_workshop_logs" => "Workshop logs", + "fm_workshops" => "Workshops", + "fm_participants" => "Event registrations", + "fm_payments" => "Payments", + "fm_fundings" => "Scholarships", + "fm_invoices" => "Invoices", + "fm_quotations" => "Quotes" }.freeze FIELD_GROUPS = { @@ -130,10 +133,9 @@ class FmRolodex < ApplicationRecord "Organization & affiliations" => { "Classification & status" => %w[Classification Status], "Organization" => %w[Organization OrgID WebSite Mission PrimaryContact PrimaryContactID], - "Job affiliation" => %w[Title Department Dept2ndName], - "Facilitator affiliation" => %w[FacilitatorSinceMonth FacilitatorSinceYear - FacilitatorStatusPending HowTrained TrainingMotivation LifeExperiences - Settings BBSLicense] + JOB_AFFILIATION => %w[Title Department Dept2ndName], + FACILITATOR_AFFILIATION => %w[FacilitatorSinceMonth FacilitatorSinceYear FacilitatorStatusPending + HowTrained TrainingMotivation LifeExperiences Settings BBSLicense] }, "Sector & age group" => %w[PrimaryServiceArea OtherServiceArea PrimaryAgeGroup], "Record keeping" => %w[ID Delete DuplicateStatus EnteredBy DateEntered ModifiedBy DateTimeModified UserID Password], @@ -179,10 +181,20 @@ def addresses @addresses ||= contact_records(FmAddress, primary_ids: data.values_at("PrimaryAddrsID", "WorksiteAddrsID")) end + def job_affiliations + @job_affiliations ||= FmPeopleToOrganization.find_by_data("PersonID", fm_id).order(:fm_id).to_a + end + + def facilitator_affiliations + @facilitator_affiliations ||= FmPersonnel.find_by_data("PersonID", fm_id).order(:fm_id).to_a + end + def records_listed_under(label) case label when PHONES then phone_numbers when ADDRESSES then addresses + when JOB_AFFILIATION then job_affiliations + when FACILITATOR_AFFILIATION then facilitator_affiliations else [] end end diff --git a/app/views/fm_archives/_contact_records.html.erb b/app/views/fm_archives/_listed_records.html.erb similarity index 100% rename from app/views/fm_archives/_contact_records.html.erb rename to app/views/fm_archives/_listed_records.html.erb diff --git a/app/views/fm_archives/show.html.erb b/app/views/fm_archives/show.html.erb index d87e466e58..9cb4bd018a 100644 --- a/app/views/fm_archives/show.html.erb +++ b/app/views/fm_archives/show.html.erb @@ -39,7 +39,7 @@

    <%= sub_label %>

    <% end %> <%= render "fields", record: @record, fields: fields %> - <%= render "contact_records", owner: @record, records: @record.records_listed_under(sub_label) %> + <%= render "listed_records", owner: @record, records: @record.records_listed_under(sub_label) %>
<% end %> <% end %> @@ -61,7 +61,7 @@ fm_related_table_highlight(@record, info[:table]) || "border-gray-200") do %>

<%= label %> (<%= info[:records].total_entries %>)

<% if (counterpart = @record.portal_counterpart(info[:table])) %> -

In the portal as <%= counterpart.model_name.human.pluralize.downcase %>

+

In the portal as <%= counterpart.downcase %>

<% end %>
<% info[:records].each do |record| %> diff --git a/spec/decorators/fm_people_to_organization_decorator_spec.rb b/spec/decorators/fm_people_to_organization_decorator_spec.rb new file mode 100644 index 0000000000..90873da28d --- /dev/null +++ b/spec/decorators/fm_people_to_organization_decorator_spec.rb @@ -0,0 +1,16 @@ +require "rails_helper" + +RSpec.describe FmPeopleToOrganizationDecorator do + it "reads as the person's title at the organization" do + FmOrganization.create!(fm_id: "ORG1", data: { "OrgName" => "Sample Shelter" }) + affiliation = FmPeopleToOrganization.new(data: { "OrgID" => "ORG1", "Title" => "Case Manager" }).decorate + + expect([ affiliation.kind, affiliation.summary ]).to eq([ "Case Manager", "Sample Shelter" ]) + end + + it "falls back to the organization ID when the archive lacks the organization" do + affiliation = FmPeopleToOrganization.new(data: { "OrgID" => "ORG9", "Title" => "" }).decorate + + expect([ affiliation.kind, affiliation.summary ]).to eq([ "Affiliation", "ORG9" ]) + end +end diff --git a/spec/decorators/fm_personnel_decorator_spec.rb b/spec/decorators/fm_personnel_decorator_spec.rb new file mode 100644 index 0000000000..0b55c0ef5e --- /dev/null +++ b/spec/decorators/fm_personnel_decorator_spec.rb @@ -0,0 +1,17 @@ +require "rails_helper" + +RSpec.describe FmPersonnelDecorator do + it "reads as the role in a program at its organization, with its statuses" do + FmOrganization.create!(fm_id: "ORG1", data: { "OrgName" => "Sample Shelter" }) + FmProject.create!(fm_id: "P1", data: { "ProjectName" => "Adult Windows", "OrgID" => "ORG1" }) + personnel = FmPersonnel.new(data: { "ProjectID" => "P1", "Role" => "Facilitator", "Status" => "Active", "AgreementStatus" => "Signed" }).decorate + + expect([ personnel.kind, personnel.summary ]).to eq([ "Facilitator", "Adult Windows at Sample Shelter · Active · Signed" ]) + end + + it "falls back to the program ID when the archive lacks the program" do + personnel = FmPersonnel.new(data: { "ProjectID" => "P9" }).decorate + + expect([ personnel.kind, personnel.summary ]).to eq([ "Personnel", "P9" ]) + end +end diff --git a/spec/models/fm_rolodex_spec.rb b/spec/models/fm_rolodex_spec.rb index f3f3fcdc7e..89ed64c4f4 100644 --- a/spec/models/fm_rolodex_spec.rb +++ b/spec/models/fm_rolodex_spec.rb @@ -93,6 +93,31 @@ end end + describe "#job_affiliations and #facilitator_affiliations" do + let(:rolodex) { described_class.create!(fm_id: "00901", data: {}) } + + before do + FmPeopleToOrganization.create!(fm_id: "R2", data: { "PersonID" => "00901" }) + FmPeopleToOrganization.create!(fm_id: "R1", data: { "PersonID" => "00901" }) + FmPeopleToOrganization.create!(fm_id: "R9", data: { "PersonID" => "00777" }) + FmPersonnel.create!(fm_id: "PS1", data: { "PersonID" => "00901" }) + end + + it "lists the person's organization affiliation rows" do + expect(rolodex.job_affiliations.map(&:fm_id)).to eq(%w[R1 R2]) + end + + it "lists the person's personnel rows" do + expect(rolodex.facilitator_affiliations.map(&:fm_id)).to eq(%w[PS1]) + end + + it "keeps an affiliation subcard when the person has affiliation rows but no affiliation fields" do + expect(rolodex.grouped_data).to eq( + "Organization & affiliations" => { FmRolodex::JOB_AFFILIATION => {}, FmRolodex::FACILITATOR_AFFILIATION => {} } + ) + end + end + it "leaves tables without field groups as one unlabeled group" do organization = FmOrganization.new(data: { "OrgName" => "Shelter" }) diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index c5cdfbcf50..e124b5ab9a 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -47,6 +47,18 @@ expect(response.body).not_to include("MailingList") end + it "lists a rolodex person's job and facilitator affiliations in their subcards" do + FmRolodex.create!(fm_id: "00006", data: { "ID" => "00006" }) + FmOrganization.create!(fm_id: "ORG1", data: { "OrgName" => "Sample Shelter" }) + FmProject.create!(fm_id: "P1", data: { "ProjectName" => "Adult Windows", "OrgID" => "ORG1" }) + FmPeopleToOrganization.create!(fm_id: "R1", data: { "PersonID" => "00006", "OrgID" => "ORG1", "Title" => "Case Manager" }) + FmPersonnel.create!(fm_id: "PS1", data: { "PersonID" => "00006", "ProjectID" => "P1", "Role" => "Facilitator" }) + + get fm_archive_path("00006", table: "rolodexes") + + expect(response.body).to match(/Job affiliation.*Case Manager.*R1.*Sample Shelter.*Facilitator affiliation.*Facilitator.*PS1.*Adult Windows at Sample Shelter/m) + end + it "lists a primary phone or address the archive lacks as not found" do FmRolodex.create!(fm_id: "00006", data: { "ID" => "00006", "PrimaryPhoneID" => "75999", "WorksiteAddrsID" => "74999" }) @@ -94,6 +106,7 @@ FmAddress.create!(fm_id: "74001", data: { "RolodexID" => "00006" }) FmFunding.create!(fm_id: "FND1", data: { "FunderID" => "00006" }) FmNote.create!(fm_id: "N1", data: { "RolodexID" => "00006" }) + FmActivity.create!(fm_id: "A1", data: { "ID" => "00006" }) get fm_archive_path("00006", table: "rolodexes") @@ -101,6 +114,7 @@ expect(frame.call("fm_workshop_logs")).to include("bg-orange-100", "In the portal as workshop logs") expect(frame.call("fm_addresses")).to include("bg-orange-100", "In the portal as addresses") expect(frame.call("fm_fundings")).to include("bg-orange-100", "In the portal as scholarships") + expect(frame.call("fm_activities")).to include("bg-orange-100", "In the portal as communications") expect(frame.call("fm_notes")).not_to include("bg-orange-100", "In the portal") reference = response.body.split("Records pointing here").last From e713af405a85708b94f6a6c8ac5d7ceebdcbfbed Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:35:59 -0400 Subject: [PATCH 29/36] Note the years FileMaker contacts cover in the field reference Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/fm_rolodex.rb | 2 +- app/views/fm_archives/_field_reference.html.erb | 3 +++ spec/requests/fm_archives_spec.rb | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index 5007348284..7095f5a649 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -150,7 +150,7 @@ class FmRolodex < ApplicationRecord HAS_MANY = { "fm_addresses" => { via: "RolodexID", label: "Addresses" }, "fm_phone_numbers" => { via: "RolodexID", label: "Phone Numbers" }, - "fm_contacts" => { via: "ContacteeID", label: "Contacts" }, + "fm_contacts" => { via: "ContacteeID", label: "Contacts", note: "Data from 2003–2018" }, "fm_notes" => { via: "RolodexID", label: "Notes" }, "fm_activities" => { via: "ID", label: "Activities" }, "fm_form_letters" => { via: "SignatureID", label: "Form Letters (Signed)" }, diff --git a/app/views/fm_archives/_field_reference.html.erb b/app/views/fm_archives/_field_reference.html.erb index 75e13c3675..bcf38170b3 100644 --- a/app/views/fm_archives/_field_reference.html.erb +++ b/app/views/fm_archives/_field_reference.html.erb @@ -44,6 +44,9 @@ <% end %> via <%= config[:via] %> + <% if config[:note] %> + <%= config[:note] %> + <% end %> <% end %> diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index e124b5ab9a..e00fcbb979 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -121,6 +121,7 @@ expect(reference).to match(%r{
  • \s*]*>Workshop Logs}) expect(reference).to match(%r{>Personnel\s*]*>\(Affiliations\)}) expect(reference).not_to include("(Workshop logs)") + expect(reference).to match(%r{>Contacts.*ContacteeID\s*]*>Data from 2003–2018}m) expect(reference).to match(%r{>Funding\s*]*>\(Scholarships\)}) expect(reference).not_to match(%r{
  • \s*]*>Notes}) end From 9b8ab4b20f3e90d715c869a9ff507346f934e585 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:36:38 -0400 Subject: [PATCH 30/36] Note the years FileMaker activities cover in the field reference Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/fm_rolodex.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index 7095f5a649..1ac89912e5 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -152,7 +152,7 @@ class FmRolodex < ApplicationRecord "fm_phone_numbers" => { via: "RolodexID", label: "Phone Numbers" }, "fm_contacts" => { via: "ContacteeID", label: "Contacts", note: "Data from 2003–2018" }, "fm_notes" => { via: "RolodexID", label: "Notes" }, - "fm_activities" => { via: "ID", label: "Activities" }, + "fm_activities" => { via: "ID", label: "Activities", note: "Data from 2003–2025" }, "fm_form_letters" => { via: "SignatureID", label: "Form Letters (Signed)" }, "fm_people_to_organizations" => { via: "PersonID", label: "Org Affiliations" }, "fm_personnels" => { via: "PersonID", label: "Personnel" }, From aa3a919f6ee09a1268d22c5a85089ef87da607b0 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:37:30 -0400 Subject: [PATCH 31/36] Map FileMaker notes to portal comments Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/fm_rolodex.rb | 1 + spec/helpers/filemaker_helper_spec.rb | 4 ++-- spec/requests/fm_archives_spec.rb | 6 ++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index 1ac89912e5..6b214ccedf 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -110,6 +110,7 @@ class FmRolodex < ApplicationRecord PORTAL_COUNTERPARTS = { "fm_addresses" => "Addresses", "fm_phone_numbers" => "Contact methods", + "fm_notes" => "Comments", "fm_activities" => "Communications", "fm_people_to_organizations" => "Affiliations", "fm_personnels" => "Affiliations", diff --git a/spec/helpers/filemaker_helper_spec.rb b/spec/helpers/filemaker_helper_spec.rb index 7867870add..68977695d8 100644 --- a/spec/helpers/filemaker_helper_spec.rb +++ b/spec/helpers/filemaker_helper_spec.rb @@ -113,7 +113,7 @@ end it "skips a table without a portal counterpart" do - expect(helper.fm_portal_counterpart_name(rolodex, "fm_notes", "Notes")).to be_nil + expect(helper.fm_portal_counterpart_name(rolodex, "fm_volunteers", "Volunteer Records")).to be_nil end end @@ -125,7 +125,7 @@ end it "leaves a table with no portal counterpart uncolored" do - expect(helper.fm_related_table_highlight(rolodex, "fm_notes")).to be_nil + expect(helper.fm_related_table_highlight(rolodex, "fm_volunteers")).to be_nil end end diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index e00fcbb979..7f6d605166 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -106,6 +106,7 @@ FmAddress.create!(fm_id: "74001", data: { "RolodexID" => "00006" }) FmFunding.create!(fm_id: "FND1", data: { "FunderID" => "00006" }) FmNote.create!(fm_id: "N1", data: { "RolodexID" => "00006" }) + FmVolunteer.create!(fm_id: "V1", data: { "VRecID" => "00006" }) FmActivity.create!(fm_id: "A1", data: { "ID" => "00006" }) get fm_archive_path("00006", table: "rolodexes") @@ -115,7 +116,8 @@ expect(frame.call("fm_addresses")).to include("bg-orange-100", "In the portal as addresses") expect(frame.call("fm_fundings")).to include("bg-orange-100", "In the portal as scholarships") expect(frame.call("fm_activities")).to include("bg-orange-100", "In the portal as communications") - expect(frame.call("fm_notes")).not_to include("bg-orange-100", "In the portal") + expect(frame.call("fm_notes")).to include("bg-orange-100", "In the portal as comments") + expect(frame.call("fm_volunteers")).not_to include("bg-orange-100", "In the portal") reference = response.body.split("Records pointing here").last expect(reference).to match(%r{
  • \s*]*>Workshop Logs}) @@ -123,7 +125,7 @@ expect(reference).not_to include("(Workshop logs)") expect(reference).to match(%r{>Contacts.*ContacteeID\s*]*>Data from 2003–2018}m) expect(reference).to match(%r{>Funding\s*]*>\(Scholarships\)}) - expect(reference).not_to match(%r{
  • \s*]*>Notes}) + expect(reference).not_to match(%r{
  • \s*]*>Volunteer Records}) end it "leaves related records unhighlighted on tables without cards" do From 2968bf9aac1d75aa20bb6daa8864a5d70cfc2e50 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:42:50 -0400 Subject: [PATCH 32/36] Mark rolodex fields that import straight into a portal column Shows at a glance which archive fields could move into the portal as-is, with the target column on hover. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/concerns/fm_archive.rb | 1 + app/models/fm_rolodex.rb | 17 +++++++++++++++++ app/views/fm_archives/_field_reference.html.erb | 11 +++++++++-- spec/requests/fm_archives_spec.rb | 14 ++++++++++++-- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/app/models/concerns/fm_archive.rb b/app/models/concerns/fm_archive.rb index 3ada69eb3d..3b4b719cbe 100644 --- a/app/models/concerns/fm_archive.rb +++ b/app/models/concerns/fm_archive.rb @@ -6,6 +6,7 @@ module FmArchive LISTED_FIELDS = [].freeze TALL_GROUPS = [].freeze PORTAL_COUNTERPARTS = {}.freeze + PORTAL_FIELDS = {}.freeze included do before_validation :set_defaults diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index 6b214ccedf..6346636168 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -107,6 +107,23 @@ class FmRolodex < ApplicationRecord FACILITATOR_AFFILIATION = "Facilitator affiliation" LISTED_FIELDS = %w[PrimaryPhoneID PrimaryAddrsID WorksiteAddrsID].freeze TALL_GROUPS = [ "Organization & affiliations" ].freeze + PORTAL_FIELDS = { + "ID" => "people.filemaker_code", + "FirstName" => "people.first_name", + "LastName" => "people.last_name", + "Pronouns" => "people.pronouns", + "EMail" => "people.email", + "EmailType" => "people.email_type", + "BestTimeToCall" => "people.best_time_to_call", + "DateOfBirth" => "people.date_of_birth", + "Ethnicity" => "people.racial_ethnic_identity", + "Comments" => "people.notes", + "Title" => "affiliations.title", + "PrimaryServiceArea" => "the person's primary sector", + "OtherServiceArea" => "the person's additional sectors", + "PrimaryAgeGroup" => "the person's primary age group" + }.freeze + PORTAL_COUNTERPARTS = { "fm_addresses" => "Addresses", "fm_phone_numbers" => "Contact methods", diff --git a/app/views/fm_archives/_field_reference.html.erb b/app/views/fm_archives/_field_reference.html.erb index bcf38170b3..8eb417a2d2 100644 --- a/app/views/fm_archives/_field_reference.html.erb +++ b/app/views/fm_archives/_field_reference.html.erb @@ -4,14 +4,21 @@

    Every field on a <%= model.name %> record. Grayed-out fields are blank on this one, so the cards above leave them out.

    -

    Fields

    +

    Fields

    +

    + Imports straight into a portal field (hover to see which) +

    <% model.flat_field_groups.merge(FmArchive::OTHER_FIELDS => record.ungrouped_keys).compact_blank.each do |label, keys| %>
    <%= label %>
    <% keys.each do |key| %> - "><%= key %> + <% portal_field = model::PORTAL_FIELDS[key] %> + <%= tag.span key, class: class_names("rounded px-2 py-0.5 font-mono text-xs", + portal_field ? "bg-orange-100" : "bg-white", + record.data[key].present? ? "text-gray-700" : "text-gray-400"), + title: portal_field && "Imports to #{portal_field}" %> <% end %>
    diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index 7f6d605166..61614b9256 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -84,8 +84,8 @@ cards, reference = response.body.split("Available fields & associations") expect(cards).to include("LastName") expect(cards).not_to include("MiddleName", "zcSort") - expect(reference).to match(/text-gray-400">MiddleNameLastName]*>MiddleName]*>LastName "00006", "FirstName" => "Abe", "Gender" => "F" }) + + get fm_archive_path("00006", table: "rolodexes") + + reference = response.body.split("Available fields & associations").last + expect(reference).to match(%r{FirstName}) + expect(reference).to match(%r{Gender}) + end + it "leaves the field reference off tables without field groups" do FmProject.create!(fm_id: "P2775") From 9650c71ca35894f014080262d136ecb0f9c1d677 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:43:38 -0400 Subject: [PATCH 33/36] Map rolodex entry and modification stamps to portal audit columns The FileMaker ID already lives on as the person's filemaker_code, so it isn't a field to import. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/fm_rolodex.rb | 7 +++++-- spec/requests/fm_archives_spec.rb | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index 6346636168..ddf7541e28 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -108,7 +108,6 @@ class FmRolodex < ApplicationRecord LISTED_FIELDS = %w[PrimaryPhoneID PrimaryAddrsID WorksiteAddrsID].freeze TALL_GROUPS = [ "Organization & affiliations" ].freeze PORTAL_FIELDS = { - "ID" => "people.filemaker_code", "FirstName" => "people.first_name", "LastName" => "people.last_name", "Pronouns" => "people.pronouns", @@ -121,7 +120,11 @@ class FmRolodex < ApplicationRecord "Title" => "affiliations.title", "PrimaryServiceArea" => "the person's primary sector", "OtherServiceArea" => "the person's additional sectors", - "PrimaryAgeGroup" => "the person's primary age group" + "PrimaryAgeGroup" => "the person's primary age group", + "DateEntered" => "people.created_at", + "DateTimeModified" => "people.updated_at", + "EnteredBy" => "people.created_by_id (matched to a portal user)", + "ModifiedBy" => "people.updated_by_id (matched to a portal user)" }.freeze PORTAL_COUNTERPARTS = { diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index 61614b9256..ecab4b7b80 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -146,6 +146,8 @@ reference = response.body.split("Available fields & associations").last expect(reference).to match(%r{FirstName}) expect(reference).to match(%r{Gender}) + expect(reference).to match(%r{ID}) + expect(reference).to match(%r{DateEntered}) end it "leaves the field reference off tables without field groups" do From 7d091d873639e0b17c9447b7b36343d27ae48991 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:47:04 -0400 Subject: [PATCH 34/36] Key the rolodex stamp fields that need a staff-to-user lookup Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/concerns/fm_archive.rb | 1 + app/models/fm_rolodex.rb | 5 +++-- app/views/fm_archives/_field_reference.html.erb | 13 +++++++++---- spec/requests/fm_archives_spec.rb | 2 ++ 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/models/concerns/fm_archive.rb b/app/models/concerns/fm_archive.rb index 3b4b719cbe..74176bd060 100644 --- a/app/models/concerns/fm_archive.rb +++ b/app/models/concerns/fm_archive.rb @@ -7,6 +7,7 @@ module FmArchive TALL_GROUPS = [].freeze PORTAL_COUNTERPARTS = {}.freeze PORTAL_FIELDS = {}.freeze + USER_LOOKUP_FIELDS = [].freeze included do before_validation :set_defaults diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index ddf7541e28..446bec62af 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -123,9 +123,10 @@ class FmRolodex < ApplicationRecord "PrimaryAgeGroup" => "the person's primary age group", "DateEntered" => "people.created_at", "DateTimeModified" => "people.updated_at", - "EnteredBy" => "people.created_by_id (matched to a portal user)", - "ModifiedBy" => "people.updated_by_id (matched to a portal user)" + "EnteredBy" => "people.created_by_id", + "ModifiedBy" => "people.updated_by_id" }.freeze + USER_LOOKUP_FIELDS = %w[EnteredBy ModifiedBy].freeze PORTAL_COUNTERPARTS = { "fm_addresses" => "Addresses", diff --git a/app/views/fm_archives/_field_reference.html.erb b/app/views/fm_archives/_field_reference.html.erb index 8eb417a2d2..5d20dfba8e 100644 --- a/app/views/fm_archives/_field_reference.html.erb +++ b/app/views/fm_archives/_field_reference.html.erb @@ -5,9 +5,14 @@

    Fields

    -

    - Imports straight into a portal field (hover to see which) -

    +
    +

    + Imports straight into a portal field (hover to see which) +

    + <% if model::USER_LOOKUP_FIELDS.any? %> +

    * Needs a FileMaker staff → portal user lookup

    + <% end %> +
    <% model.flat_field_groups.merge(FmArchive::OTHER_FIELDS => record.ungrouped_keys).compact_blank.each do |label, keys| %>
    @@ -15,7 +20,7 @@
    <% keys.each do |key| %> <% portal_field = model::PORTAL_FIELDS[key] %> - <%= tag.span key, class: class_names("rounded px-2 py-0.5 font-mono text-xs", + <%= tag.span "#{key}#{"*" if model::USER_LOOKUP_FIELDS.include?(key)}", class: class_names("rounded px-2 py-0.5 font-mono text-xs", portal_field ? "bg-orange-100" : "bg-white", record.data[key].present? ? "text-gray-700" : "text-gray-400"), title: portal_field && "Imports to #{portal_field}" %> diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index ecab4b7b80..dd45233779 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -148,6 +148,8 @@ expect(reference).to match(%r{Gender}) expect(reference).to match(%r{ID}) expect(reference).to match(%r{DateEntered}) + expect(reference).to match(%r{title="Imports to people.created_by_id">EnteredBy\*}) + expect(reference).to include("* Needs a FileMaker staff → portal user lookup") end it "leaves the field reference off tables without field groups" do From 11cec68b0da41aa486d11da67807b101c1f39c08 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:47:32 -0400 Subject: [PATCH 35/36] Map rolodex emails and facilitator-since date to portal columns Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/fm_rolodex.rb | 5 ++++- spec/requests/fm_archives_spec.rb | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index 446bec62af..a788dfa92d 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -111,13 +111,16 @@ class FmRolodex < ApplicationRecord "FirstName" => "people.first_name", "LastName" => "people.last_name", "Pronouns" => "people.pronouns", - "EMail" => "people.email", + "EmailPrimary" => "people.email", + "EMail" => "people.email_2", "EmailType" => "people.email_type", "BestTimeToCall" => "people.best_time_to_call", "DateOfBirth" => "people.date_of_birth", "Ethnicity" => "people.racial_ethnic_identity", "Comments" => "people.notes", "Title" => "affiliations.title", + "FacilitatorSinceMonth" => "people.member_since (with FacilitatorSinceYear)", + "FacilitatorSinceYear" => "people.member_since (with FacilitatorSinceMonth)", "PrimaryServiceArea" => "the person's primary sector", "OtherServiceArea" => "the person's additional sectors", "PrimaryAgeGroup" => "the person's primary age group", diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index dd45233779..b2ec4cadd3 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -150,6 +150,7 @@ expect(reference).to match(%r{DateEntered}) expect(reference).to match(%r{title="Imports to people.created_by_id">EnteredBy\*}) expect(reference).to include("* Needs a FileMaker staff → portal user lookup") + expect(reference).to match(%r{title="Imports to people.email">EmailPrimary}) end it "leaves the field reference off tables without field groups" do From 346d7792b709017ca881a8bceec231f9a3d52db4 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 13 Oct 2026 08:51:09 -0400 Subject: [PATCH 36/36] Map FileMaker EMail to the person's main email FileMaker keeps no second email, so nothing feeds email_2. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/fm_rolodex.rb | 2 +- spec/requests/fm_archives_spec.rb | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index a788dfa92d..fb37537941 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -112,7 +112,7 @@ class FmRolodex < ApplicationRecord "LastName" => "people.last_name", "Pronouns" => "people.pronouns", "EmailPrimary" => "people.email", - "EMail" => "people.email_2", + "EMail" => "people.email", "EmailType" => "people.email_type", "BestTimeToCall" => "people.best_time_to_call", "DateOfBirth" => "people.date_of_birth", diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index b2ec4cadd3..5a7f5ba4af 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -151,6 +151,7 @@ expect(reference).to match(%r{title="Imports to people.created_by_id">EnteredBy\*}) expect(reference).to include("* Needs a FileMaker staff → portal user lookup") expect(reference).to match(%r{title="Imports to people.email">EmailPrimary}) + expect(reference).to match(%r{title="Imports to people.email">EMail}) end it "leaves the field reference off tables without field groups" do