diff --git a/app/decorators/fm_address_decorator.rb b/app/decorators/fm_address_decorator.rb new file mode 100644 index 0000000000..22b0b17466 --- /dev/null +++ b/app/decorators/fm_address_decorator.rb @@ -0,0 +1,18 @@ +class FmAddressDecorator < ApplicationDecorator + def kind + data["AddrsType"].presence || "Address" + end + + def summary + state_and_zip = data.values_at("AddrsState", "AddrsPostalCode").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/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_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..62365d52bd --- /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/helpers/filemaker_helper.rb b/app/helpers/filemaker_helper.rb index 349b083da5..afbdfe495f 100644 --- a/app/helpers/filemaker_helper.rb +++ b/app/helpers/filemaker_helper.rb @@ -38,7 +38,27 @@ 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. + 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 + + # 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) + 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. + def fm_related_table_highlight(record, table) + "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 90e0f02975..74176bd060 100644 --- a/app/models/concerns/fm_archive.rb +++ b/app/models/concerns/fm_archive.rb @@ -1,6 +1,14 @@ module FmArchive extend ActiveSupport::Concern + FIELD_GROUPS = {}.freeze + OTHER_FIELDS = "Other fields" + LISTED_FIELDS = [].freeze + TALL_GROUPS = [].freeze + PORTAL_COUNTERPARTS = {}.freeze + PORTAL_FIELDS = {}.freeze + USER_LOOKUP_FIELDS = [].freeze + included do before_validation :set_defaults end @@ -13,6 +21,45 @@ 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 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 => { 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.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 + + # What the portal calls its own records of the kind a related archive table holds. + def portal_counterpart(table) + self.class::PORTAL_COUNTERPARTS[table] + end + + # Related records a subcard lists below its fields. + def records_listed_under(_label) + [] end private diff --git a/app/models/fm_rolodex.rb b/app/models/fm_rolodex.rb index 4f1a3a9410..fb37537941 100644 --- a/app/models/fm_rolodex.rb +++ b/app/models/fm_rolodex.rb @@ -101,29 +101,137 @@ class FmRolodex < ApplicationRecord "WorksiteAddrsID" => "fm_addresses" }.freeze + 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_FIELDS = { + "FirstName" => "people.first_name", + "LastName" => "people.last_name", + "Pronouns" => "people.pronouns", + "EmailPrimary" => "people.email", + "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", + "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", + "DateEntered" => "people.created_at", + "DateTimeModified" => "people.updated_at", + "EnteredBy" => "people.created_by_id", + "ModifiedBy" => "people.updated_by_id" + }.freeze + USER_LOOKUP_FIELDS = %w[EnteredBy ModifiedBy].freeze + + PORTAL_COUNTERPARTS = { + "fm_addresses" => "Addresses", + "fm_phone_numbers" => "Contact methods", + "fm_notes" => "Comments", + "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 = { + "Identity" => %w[NamePrefix FirstName MiddleName LastName Suffix Pronouns Gender Gender2ndPerson isAgency__c + 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[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], + "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] + }.freeze + 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", note: "Data from 2003–2018" }, "fm_notes" => { via: "RolodexID", label: "Notes" }, + "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" }, + "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 + @phone_numbers ||= contact_records(FmPhoneNumber, primary_ids: data.values_at("PrimaryPhoneID")) + end + + 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 + + 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.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/_field_reference.html.erb b/app/views/fm_archives/_field_reference.html.erb new file mode 100644 index 0000000000..5d20dfba8e --- /dev/null +++ b/app/views/fm_archives/_field_reference.html.erb @@ -0,0 +1,68 @@ +<% model = record.class %> +
+

Available fields & associations

+

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

+
+
+

Fields

+
+

+ 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| %> +
+
<%= label %>
+
+ <% keys.each do |key| %> + <% portal_field = model::PORTAL_FIELDS[key] %> + <%= 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}" %> + <% 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

+ <%= 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" %> + <% if (portal_name = fm_portal_counterpart_name(record, table, config[:label])) %> + (<%= portal_name %>) + <% end %> + via + <%= config[:via] %> + <% if config[:note] %> + <%= config[:note] %> + <% end %> +
  • + <% end %> +
+
+
+
+
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/_listed_records.html.erb b/app/views/fm_archives/_listed_records.html.erb new file mode 100644 index 0000000000..80652052ea --- /dev/null +++ b/app/views/fm_archives/_listed_records.html.erb @@ -0,0 +1,29 @@ +<% if records.any? %> + +<% end %> 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..dc0382e3f2 --- /dev/null +++ b/app/views/fm_archives/_related_legend.html.erb @@ -0,0 +1,3 @@ +

+ 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 e45d91251d..9cb4bd018a 100644 --- a/app/views/fm_archives/show.html.erb +++ b/app/views/fm_archives/show.html.erb @@ -20,49 +20,69 @@ <% 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" %> +
+ <% @record.grouped_data.each do |label, subgroups| %> +
"> + <% if label %> +

<%= label %>

+ <% end %> +
+ <% subgroups.each do |sub_label, fields| %> + <% if sub_label && @record.single_field_group?(sub_label) %> +
+

<%= sub_label %>

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

<%= sub_label %>

+ <% end %> + <%= render "fields", record: @record, fields: fields %> + <%= render "listed_records", owner: @record, records: @record.records_listed_under(sub_label) %> +
<% end %> -
+ <% end %>
- <% end %> -
+ + <% end %>
<% if @backlinks.any? %>
-

Related Records

- <% @backlinks.each do |label, info| %> - <%= turbo_frame_tag "backlink_#{info[:table]}", class: "block py-3 border-b border-gray-200 last:border-0" 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" } %> +

Related Records

+ <% if @record.class::PORTAL_COUNTERPARTS.any? %> + <%= 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", + 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.downcase %>

<% 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 } } %> +
+ <% 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 %> + + <% if @record.class::FIELD_GROUPS.any? %> + <%= render "field_reference", record: @record %> + <% end %>
diff --git a/config/features.yml b/config/features.yml index 981ec2c8c5..c9a4e15526 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4273,3 +4273,20 @@ 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 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/decorators/fm_address_decorator_spec.rb b/spec/decorators/fm_address_decorator_spec.rb new file mode 100644 index 0000000000..b824e23269 --- /dev/null +++ b/spec/decorators/fm_address_decorator_spec.rb @@ -0,0 +1,26 @@ +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 "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 +end 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/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/helpers/filemaker_helper_spec.rb b/spec/helpers/filemaker_helper_spec.rb index 777d2cf852..68977695d8 100644 --- a/spec/helpers/filemaker_helper_spec.rb +++ b/spec/helpers/filemaker_helper_spec.rb @@ -84,6 +84,51 @@ 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 "#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 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 + end + + it "skips a table without a portal counterpart" do + expect(helper.fm_portal_counterpart_name(rolodex, "fm_volunteers", "Volunteer Records")).to be_nil + 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 "leaves a table with no portal counterpart uncolored" do + expect(helper.fm_related_table_highlight(rolodex, "fm_volunteers")).to be_nil + end + end + describe "#filemaker_code_hint" do before { FmRolodex.create!(fm_id: "00006") } diff --git a/spec/models/fm_rolodex_spec.rb b/spec/models/fm_rolodex_spec.rb new file mode 100644 index 0000000000..89ed64c4f4 --- /dev/null +++ b/spec/models/fm_rolodex_spec.rb @@ -0,0 +1,126 @@ +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", "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" } }, + "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]) + 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" => { + "Classification & status" => { "Status" => "Active" }, + "Organization" => { "Organization" => "Shelter" }, + "Job affiliation" => { "Title" => "Director" } + } + ) + 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" => { 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" => { nil => { "FirstName" => "Abe" } }) + end + + it "leaves the primary phone and address IDs to the lists below" do + 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" => { FmRolodex::ADDRESSES => {}, "Mailing list" => { "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("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.flat_field_groups.values.flatten + + expect(grouped).to match_array(documented) + 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 + + 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 + + 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" }) + + 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 e182b87d9b..5a7f5ba4af 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -17,6 +17,151 @@ 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.*LastName.*Notes.*Comments.*Record keeping/m) + end + + 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(/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*]*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 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" }) + + 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 + 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 "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(/]*>MiddleName]*>LastName "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"[^>]*>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 "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" }) + 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") + + 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")).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")).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}) + 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*]*>Volunteer Records}) + 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-orange-100") + end + + it "marks fields that import straight into a portal column orange, naming the column" do + FmRolodex.create!(fm_id: "00006", data: { "ID" => "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}) + 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") + 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 + 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")