diff --git a/app/controllers/concerns/tag_assignable.rb b/app/controllers/concerns/tag_assignable.rb
index 8b4a932750..98b53f0c4b 100644
--- a/app/controllers/concerns/tag_assignable.rb
+++ b/app/controllers/concerns/tag_assignable.rb
@@ -9,6 +9,8 @@ def assign_associations(record, param_key: nil)
selected_category_ids = Array(params[key][:category_ids]).reject(&:blank?).map(&:to_i)
selected = Category.where(id: selected_category_ids).to_a
+ categories_before = record.categories.to_a
+
if params[key].key?(:managed_category_type_ids)
# The form only edits certain category types (e.g. age ranges + workshop
# settings). Preserve taggings of every other type the form never shows so
@@ -20,10 +22,14 @@ def assign_associations(record, param_key: nil)
else
record.categories = selected
end
+ categories_after = record.categories.to_a
- if params[key].key?(:sector_ids)
+ sectors_changed = params[key].key?(:sector_ids)
+ if sectors_changed
+ sectors_before = record.sectors.to_a
selected_sector_ids = Array(params[key][:sector_ids]).reject(&:blank?).map(&:to_i)
record.sectors = Sector.where(id: selected_sector_ids)
+ sectors_after = record.sectors.to_a
end
record.save!
@@ -34,5 +40,20 @@ def assign_associations(record, param_key: nil)
if params[key].key?(:primary_age_category_ids) && record.respond_to?(:apply_primary_age_groups!)
record.apply_primary_age_groups!(Array(params[key][:primary_age_category_ids]))
end
+
+ # These memberships change outside the record's dirty tracking, so hand the
+ # diff to the change log directly (a no-op when nothing actually moved).
+ return unless record.respond_to?(:track_membership_changes)
+
+ record.track_membership_changes(
+ categories: membership_delta(categories_before, categories_after),
+ sectors: (membership_delta(sectors_before, sectors_after) if sectors_changed)
+ )
+ end
+
+ def membership_delta(before, after)
+ before = Array(before)
+ after = Array(after)
+ { added: after - before, removed: before - after }
end
end
diff --git a/app/decorators/ahoy/event_decorator.rb b/app/decorators/ahoy/event_decorator.rb
index 54c3a95b4c..ae77fb0fcd 100644
--- a/app/decorators/ahoy/event_decorator.rb
+++ b/app/decorators/ahoy/event_decorator.rb
@@ -3,6 +3,9 @@ class EventDecorator < ApplicationDecorator
# Already surfaced in their own table columns, so redundant inside the details cell.
REDUNDANT_KEYS = %w[resource_type resource_id resource_title].freeze
+ # Fields that title the record they belong to, in the order they should lead.
+ HEADING_KEYS = %w[topic title name subject].freeze
+
# Everything the dedicated columns don't already show.
def extra_properties
properties_hash.except(*REDUNDANT_KEYS)
@@ -21,8 +24,10 @@ def changes?
def changes_summary
return [] unless changes?
- change_diffs.map do |field, diff|
+ change_diffs.filter_map do |field, diff|
diff = {} unless diff.is_a?(Hash)
+ next if blank_change?(diff)
+
{
field: field.to_s.humanize,
before: display_value(diff["before"]),
@@ -64,15 +69,25 @@ def flatten_rows(value, label = nil, depth = 0)
end
def hash_rows(hash, label, depth)
- return [ reference_row(label, hash, depth) ] if reference?(hash)
+ return reference_rows(label, hash, depth) if reference?(hash)
return [ { label: label, value: "(empty)", depth: depth } ] if hash.empty?
rows = label ? [ { label: label, value: nil, depth: depth } ] : []
child_depth = label ? depth + 1 : depth
- hash.each { |key, val| rows.concat(flatten_rows(val, humanize_key(key), child_depth)) }
+ heading_first(hash).each do |key, val|
+ child_rows = flatten_rows(val, humanize_key(key), child_depth)
+ child_rows.first[:emphasis] = true if HEADING_KEYS.include?(key.to_s) && child_rows.one?
+ rows.concat(child_rows)
+ end
rows
end
+ # A comment's topic titles its body rather than sitting beside it, so it leads.
+ def heading_first(hash)
+ headings, rest = hash.partition { |key, _| HEADING_KEYS.include?(key.to_s) }
+ headings.sort_by { |key, _| HEADING_KEYS.index(key.to_s) } + rest
+ end
+
def array_rows(array, label, depth)
return [ { label: label, value: "(empty)", depth: depth } ] if array.empty?
@@ -81,7 +96,9 @@ def array_rows(array, label, depth)
elsif array.all? { |item| reference?(item) }
header = label ? [ { label: label, value: nil, depth: depth } ] : []
child_depth = label ? depth + 1 : depth
- header + array.map { |item| reference_row(nil, item, child_depth) }
+ header + array.flat_map { |item| reference_rows(nil, item, child_depth) }
+ elsif array.all? { |item| attachment_change?(item) }
+ array.map { |item| attachment_row(label, item, depth) }
else
[ { label: label, value: array.map { |item| display_value(item) }.join(", "), depth: depth } ]
end
@@ -91,6 +108,19 @@ def reference?(item)
Analytics::EventReferenceLoader.reference?(item)
end
+ # An attachment is staged on the record and has no id until the save lands,
+ # so it travels as a filename rather than as a reference to look up.
+ def attachment_change?(item)
+ item.is_a?(Hash) && item["type"] == "ActiveStorage::Attachment" &&
+ item["action"].present? && item["id"].blank? && item["record_id"].blank?
+ end
+
+ # A file has no page to link to, so it reads as its name — kept on a removal
+ # too, since the blob it named is gone by the time anyone reads this.
+ def attachment_row(label, item, depth)
+ { label: label, depth: depth, action: item["action"], value: item["filename"].presence }
+ end
+
def named_entity?(item)
item.is_a?(Hash) && (item["name"].present? || item["title"].present?)
end
@@ -101,15 +131,52 @@ def entity_label(item)
type.present? ? "#{name} (#{type.to_s.underscore.humanize})" : name
end
+ # The link, then what the record actually said — a comment's body reads better
+ # than "a comment was added".
+ def reference_rows(label, item, depth)
+ rows = [ reference_row(label, item, depth) ]
+ rows += change_rows(item["changes"], depth + 1)
+ rows += flatten_rows(item["attributes"], nil, depth + 1) if item["attributes"].present?
+ rows
+ end
+
+ # A nested record's diffs read like the record's own: field, then before, then
+ # after. The order comes from here rather than the payload — MySQL reorders
+ # the keys of a JSON object.
+ def change_rows(diffs, depth)
+ return [] unless diffs.is_a?(Hash)
+
+ diffs.filter_map do |field, diff|
+ diff = {} unless diff.is_a?(Hash)
+ next if blank_change?(diff)
+
+ { label: humanize_key(field), depth: depth,
+ change: { before: display_value(diff["before"]), after: display_value(diff["after"]) } }
+ end
+ end
+
+ def blank_change?(diff)
+ diff["before"].blank? && diff["after"].blank?
+ end
+
def reference_row(label, item, depth)
type = item["type"] || item["record_type"]
id = item["id"] || item["record_id"]
record = find_referenced_record(type, id)
- text = record.try(:title).presence || record.try(:name).presence || "#{type} ##{id}"
+ text = safe_label(record) || "#{type} ##{id}"
{ label: label, depth: depth, action: item["action"],
link: { text: text, path: show_path_for(record) } }
end
+ # A model's own title/name can raise on records it wasn't written for, and a
+ # change log is not the place to find out.
+ def safe_label(record)
+ label = record.try(:title).presence || record.try(:name).presence
+ label.is_a?(String) ? label : label&.to_s
+ rescue StandardError
+ nil
+ end
+
# Prefer the page-level cache (one query per type, built by
# Analytics::EventReferenceLoader) and only fall back to a direct lookup when
# no cache was supplied (e.g. specs or the single-event detail page).
diff --git a/app/models/ahoy/event.rb b/app/models/ahoy/event.rb
index 4c3125b538..86ba2be484 100644
--- a/app/models/ahoy/event.rb
+++ b/app/models/ahoy/event.rb
@@ -5,4 +5,13 @@ class Ahoy::Event < ApplicationRecord
belongs_to :visit
belongs_to :user, optional: true
+
+ # Reading a record isn't a change to it. A record's change log asks what
+ # happened to it, so these stay on the admin activities index, which is where
+ # browsing belongs.
+ NON_MUTATION_PREFIXES = %w[view print search filter download].freeze
+
+ scope :mutations, -> {
+ NON_MUTATION_PREFIXES.reduce(all) { |scope, prefix| scope.where.not(arel_table[:name].matches("#{prefix}.%")) }
+ }
end
diff --git a/app/models/concerns/ahoy_trackable.rb b/app/models/concerns/ahoy_trackable.rb
index 4c8116740f..c06dd538f4 100644
--- a/app/models/concerns/ahoy_trackable.rb
+++ b/app/models/concerns/ahoy_trackable.rb
@@ -1,16 +1,48 @@
module AhoyTrackable
extend ActiveSupport::Concern
+ # Long-form bodies are stored as a readable preview, not in full: an event is a
+ # summary, and whole articles would bloat every row of ahoy_events.
+ RICH_TEXT_PREVIEW_LIMIT = 300
+
included do
after_create -> { track_create_event }
after_update -> { track_update_event }
after_destroy -> { track_lifecycle_event("destroy", @_destroy_snapshot || {}) }
- before_save :capture_pending_association_removals
+ before_save :capture_pending_changes
+ after_save :resolve_pending_association_ids
before_destroy :capture_destroy_snapshot
end
+ # Category and sector memberships are reassigned through has_many :through
+ # collection setters (categories=/sectors=) that persist immediately, outside
+ # this record's dirty tracking — so the update callbacks never see them. The
+ # code that reassigns them (TagAssignable) hands the before/after records here
+ # to fold the diff onto the change log as the record's own update event.
+ def track_membership_changes(memberships)
+ association_changes = memberships.each_with_object({}) do |(assoc_name, delta), changes|
+ entries = membership_change_entries(delta)
+ changes[assoc_name] = entries if entries.present?
+ end
+ return if association_changes.blank?
+
+ track_lifecycle_event("update", association_changes: association_changes)
+ end
+
private
+ def membership_change_entries(delta)
+ return [] if delta.blank?
+
+ added = Array(delta[:added]).map { |record| membership_entry("added", record) }
+ removed = Array(delta[:removed]).map { |record| membership_entry("removed", record) }
+ added + removed
+ end
+
+ def membership_entry(action, record)
+ { action: action, type: record.class.name, id: record.id }
+ end
+
def devise_only_changes?(changes)
auth_fields = %w[
current_sign_in_at
@@ -40,7 +72,7 @@ def track_create_event
def track_update_event
return if previously_new_record? # Skip the fake "update" that happens right after create
- changes = previous_changes.except("updated_at", "created_at")
+ changes = previous_changes.except("updated_at", "created_at").merge(@_pending_rich_text_changes.to_h)
assoc_changes = collect_association_changes
return if changes.empty? && assoc_changes.empty?
@@ -53,112 +85,142 @@ def track_update_event
track_lifecycle_event("update", extra)
end
- # Capture records marked for destruction before autosave removes them from the target
- def capture_pending_association_removals
- @_pending_association_removals = {}
-
- self.class.nested_attributes_options.each_key do |assoc_name|
- assoc = association(assoc_name.to_s)
- next unless assoc.loaded?
+ # Autosave writes children, rich text, and attachments *after* this record's own
+ # callbacks have run, clearing their dirty state on the way — so everything the
+ # update event says about them has to be read here, while it's still pending.
+ def capture_pending_changes
+ capture_pending_association_changes
+ capture_pending_rich_text_changes
+ capture_pending_attachment_changes
+ end
- marked = Array(assoc.target).compact.select(&:marked_for_destruction?)
- next if marked.empty?
+ def capture_pending_association_changes
+ @_pending_association_changes = []
- @_pending_association_removals[assoc_name] = marked.map do |record|
- { action: "removed", id: record.id, type: record.class.name }
+ self.class.nested_attributes_options.each_key do |assoc_name|
+ # A symbol: the association cache is symbol-keyed, and a string lookup hands
+ # back a fresh, unloaded association whose target is empty.
+ Array(association(assoc_name).target).compact.each do |record|
+ pending = pending_association_change(assoc_name, record)
+ @_pending_association_changes << pending if pending
end
end
end
- def collect_association_changes
- changes = {}
+ def pending_association_change(assoc_name, record)
+ if record.marked_for_destruction? || record.new_record?
+ action = record.marked_for_destruction? ? "removed" : "added"
+ return { assoc: assoc_name, record: record, action: action, attributes: content_attributes(record) }
+ end
- self.class.nested_attributes_options.each_key do |assoc_name|
- assoc = association(assoc_name.to_s)
- next unless assoc.loaded?
+ record_changes = record.changes.except("updated_at", "created_at")
+ return if record_changes.empty?
- Array(assoc.target).compact.each do |record|
- if record.previously_new_record?
- changes[assoc_name] ||= []
- changes[assoc_name] << { action: "added", id: record.id, type: record.class.name }
- else
- record_changes = record.previous_changes.except("updated_at", "created_at")
- next if record_changes.empty?
-
- changes[assoc_name] ||= []
- changes[assoc_name] << { action: "updated", id: record.id, type: record.class.name,
- changes: format_tracked_changes(record_changes) }
- end
- end
- end
+ { assoc: assoc_name, record: record, action: "updated", changes: format_tracked_changes(record_changes) }
+ end
- # Merge in removals captured before save
- if @_pending_association_removals.present?
- @_pending_association_removals.each do |assoc_name, removals|
- changes[assoc_name] ||= []
- changes[assoc_name].concat(removals)
- end
- end
+ # What the record says, minus the plumbing: an added comment should read as its
+ # body, not as a row of foreign keys. Keys, timestamps, and anything
+ # secret-shaped are left out; the child's own event keeps the full snapshot.
+ def content_attributes(record)
+ record.attributes
+ .except("id", "created_at", "updated_at")
+ .reject { |key, value| value.blank? || key.end_with?("_id", "_type") }
+ .reject { |key, _| key.match?(/password|token|secret|key|digest|salt|otp/i) }
+ end
+
+ # Ids are read now rather than at capture time: a record added through nested
+ # attributes has none until the save goes through.
+ def collect_association_changes
+ @_unresolved_association_ids = []
- # Track rich text changes
- collect_rich_text_changes(changes)
+ (@_pending_association_changes.to_a + @_pending_attachment_changes.to_a).each_with_object({}) do |pending, changes|
+ entry = { action: pending[:action], type: pending[:type] || pending[:record].class.name }
+ entry[:id] = pending[:record].id if pending[:record]
+ entry[:filename] = pending[:filename] if pending[:filename]
+ entry[:changes] = pending[:changes] if pending[:changes]
+ entry[:attributes] = pending[:attributes] if pending[:attributes].present?
+ @_unresolved_association_ids << [ entry, pending[:record] ] if pending[:record] && entry[:id].nil?
- # Track attachment changes
- collect_attachment_changes(changes)
+ (changes[pending[:assoc]] ||= []) << entry
+ end
+ end
- changes
+ # A record added through nested attributes has no id until the collection
+ # autosave runs, which is after the update event is assembled. The buffered
+ # event holds the same hash, so filling it in here fills it in there.
+ def resolve_pending_association_ids
+ @_unresolved_association_ids.to_a.each { |entry, record| entry[:id] ||= record.id }
+ @_unresolved_association_ids = nil
end
- def collect_rich_text_changes(changes)
+ # Rich text saves through the parent's autosave chain, so by the time the update
+ # event is built the body is no longer reliably readable as a change — capture it
+ # here, while it's still dirty. Keyed on the attribute (`rhino_body`), not the
+ # association, because a reader thinks of it as a field of the record; the plain
+ # text is stored rather than the markup, truncated, so an event stays readable
+ # and small.
+ def capture_pending_rich_text_changes
+ @_pending_rich_text_changes = {}
+
self.class.reflect_on_all_associations(:has_one).each do |assoc|
next if assoc.polymorphic?
next unless safe_assoc_class_name(assoc) == "ActionText::RichText"
- record = public_send(assoc.name)
- next unless record&.persisted?
+ # Reading the association would build an empty record for an untouched field.
+ # The name has to stay a symbol: the association cache is symbol-keyed, and a
+ # string lookup hands back a fresh, unloaded association whose target is nil.
+ record = association(assoc.name).target
+ # plain_text_body is derived when the rich text itself saves, which happens
+ # after this, so the body is the only diff available on a first edit.
+ diff = record&.changes&.values_at("plain_text_body", "body")&.compact&.first
+ next if diff.blank?
- rt_changes = record.previous_changes.slice("body", "plain_text_body")
- next if rt_changes.empty?
+ before, after = diff.map { |value| rich_text_preview(value) }
+ next if before == after
- changes[assoc.name] ||= []
- changes[assoc.name] << { action: "updated", id: record.id, type: "ActionText::RichText",
- changes: format_tracked_changes(rt_changes) }
+ @_pending_rich_text_changes[assoc.name.to_s.delete_prefix("rich_text_")] = [ before, after ]
end
end
- def collect_attachment_changes(changes)
- self.class.reflect_on_all_associations.each do |assoc|
- next if assoc.polymorphic?
- next unless safe_assoc_class_name(assoc) == "ActiveStorage::Attachment"
-
- if assoc.macro == :has_many
- Array(association(assoc.name.to_s).target).compact.each do |record|
- next unless record.previously_new_record?
-
- changes[assoc.name] ||= []
- changes[assoc.name] << { action: "added", type: "ActiveStorage::Attachment", record_id: record.id, blob_id: record.blob_id }
- end
- elsif assoc.macro == :has_one
- record = public_send(assoc.name)
- next unless record&.previously_new_record?
+ def rich_text_preview(value)
+ text = value.respond_to?(:to_plain_text) ? value.to_plain_text : value.to_s
+ text.squish.truncate(RICH_TEXT_PREVIEW_LIMIT)
+ end
- changes[assoc.name] ||= []
- changes[assoc.name] << { action: "added", type: "ActiveStorage::Attachment", record_id: record.id, blob_id: record.blob_id }
- end
+ # ActiveStorage stages attach/purge on the record and applies it during save, so
+ # the staged change is the only reliable description of what happened.
+ def capture_pending_attachment_changes
+ @_pending_attachment_changes = []
+ return unless respond_to?(:attachment_changes, true)
+
+ attachment_changes.each do |name, change|
+ removal = change.is_a?(ActiveStorage::Attached::Changes::DeleteOne) ||
+ change.is_a?(ActiveStorage::Attached::Changes::DeleteMany)
+
+ @_pending_attachment_changes << {
+ assoc: :"#{name}_attachment",
+ type: "ActiveStorage::Attachment",
+ action: removal ? "removed" : "added",
+ filename: removal ? removed_attachment_filenames(name) : attachment_filenames(change)
+ }.compact
end
+ end
- if @_pending_association_removals.blank?
- # Check for attachment removals via attachment_changes (Rails built-in tracking)
- return unless respond_to?(:attachment_changes, true) && attachment_changes.present?
+ def attachment_filenames(change)
+ blob_names(change.try(:blobs) || Array(change.try(:blob)))
+ end
- attachment_changes.each do |name, change|
- assoc_name = "#{name}_attachment".to_sym
- if change.is_a?(ActiveStorage::Attached::Changes::DeleteOne) || change.is_a?(ActiveStorage::Attached::Changes::DeleteMany)
- changes[assoc_name] ||= []
- changes[assoc_name] << { action: "removed", type: "ActiveStorage::Attachment" }
- end
- end
- end
+ # A staged delete carries no blob of its own, so read what is still attached:
+ # the outgoing file stays on the record until the save applies the change, and
+ # its name is all the log can keep once the blob is gone.
+ def removed_attachment_filenames(name)
+ attached = try(:"#{name}_attachments") || try(:"#{name}_attachment")
+ blob_names(Array(attached).filter_map(&:blob))
+ end
+
+ def blob_names(blobs)
+ blobs.filter_map { |blob| blob.filename.to_s.presence }.join(", ").presence
end
def capture_destroy_snapshot
@@ -175,7 +237,7 @@ def snapshot_nested_associated_records
records = {}
self.class.nested_attributes_options.each_key do |assoc_name|
- assoc = association(assoc_name.to_s)
+ assoc = association(assoc_name)
next unless assoc.loaded?
created = Array(assoc.target).compact.select(&:persisted?)
@@ -203,7 +265,7 @@ def snapshot_nested_associated_records
next if assoc.polymorphic?
next unless safe_assoc_class_name(assoc) == "ActiveStorage::Attachment"
- target = association(assoc.name.to_s).target
+ target = association(assoc.name).target
attached = Array(target).compact.select(&:persisted?)
next if attached.empty?
@@ -294,6 +356,10 @@ def safe_assoc_class_name(assoc)
def format_tracked_changes(changes)
safe_changes = changes.reject { |attr, _| attr.match?(/password|token|secret|key|digest|salt|otp/i) }
safe_changes.each_with_object({}) do |(attr, (before, after)), h|
+ # A form posts every field, so untouched blanks arrive as nil -> "". Dirty
+ # tracking counts that; a reader shouldn't have to.
+ next if before.blank? && after.blank?
+
h[attr] = { before: before, after: after }
end
end
diff --git a/app/models/sectorable_item.rb b/app/models/sectorable_item.rb
index e55d02c2f3..07c89f56b4 100644
--- a/app/models/sectorable_item.rb
+++ b/app/models/sectorable_item.rb
@@ -9,10 +9,13 @@ class SectorableItem < ApplicationRecord
before_create :skip_if_duplicate
- # Methods
+ # A tagging reads as the sector it applied. Only a workshop log carries a title
+ # and a windows type to compose with it — an organization or a person doesn't,
+ # and asking for one raised.
def title
- return id unless sectorable && sectorable.class != WorkshopLog
- "#{sectorable.title} - #{sectorable.windows_type.name if sectorable.windows_type}"
+ return sector&.name.to_s unless sectorable.is_a?(WorkshopLog)
+
+ "#{sectorable.title} - #{sectorable.windows_type&.name}"
end
private
diff --git a/app/services/analytics/event_builder.rb b/app/services/analytics/event_builder.rb
index 79bc4d105c..ce282f2b3a 100644
--- a/app/services/analytics/event_builder.rb
+++ b/app/services/analytics/event_builder.rb
@@ -20,7 +20,13 @@ def self.safe_title(resource)
resource.decorate.title
rescue
- resource.try(:title) || resource.try(:name) || resource.id
+ fallback_title(resource)
+ end
+
+ def self.fallback_title(resource)
+ resource.try(:title).presence || resource.try(:name).presence || resource.id
+ rescue StandardError
+ resource.id
end
end
end
diff --git a/app/services/analytics/event_reference_loader.rb b/app/services/analytics/event_reference_loader.rb
index a1afe6f00e..ee1634925e 100644
--- a/app/services/analytics/event_reference_loader.rb
+++ b/app/services/analytics/event_reference_loader.rb
@@ -7,7 +7,7 @@ class EventReferenceLoader
# A referenced record inside event properties: a type + id and nothing but
# reference bookkeeping — no name/title (those render as a plain label) and
# no snapshot columns.
- REFERENCE_KEYS = %w[type id record_type record_id action blob_id changes].freeze
+ REFERENCE_KEYS = %w[type id record_type record_id action blob_id changes attributes].freeze
def self.reference?(item)
return false unless item.is_a?(Hash)
diff --git a/app/views/admin/ahoy_activities/_activity_row.html.erb b/app/views/admin/ahoy_activities/_activity_row.html.erb
index e56fefa3e1..bdd5268456 100644
--- a/app/views/admin/ahoy_activities/_activity_row.html.erb
+++ b/app/views/admin/ahoy_activities/_activity_row.html.erb
@@ -27,47 +27,6 @@
<% activity = event.decorate(context: { record_cache: local_assigns[:record_cache] }) %>
- <% if activity.extra_details? %>
-
- <% if activity.changes? %>
-
- <% activity.changes_summary.each do |change| %>
-
- <%= change[:field] %>
-
-
- Before:
- <%= change[:before] %>
-
-
- After:
- <%= change[:after] %>
-
-
-
- <% end %>
-
- <% end %>
- <% activity.detail_rows.each do |row| %>
-
- <% if row[:label] %>
- <%= row[:label] %><%= ":" if row[:value] || row[:link] %>
- <% end %>
- <% if row[:link] %>
- <% if row[:action] %><%= row[:action] %><% end %>
- <% if row[:link][:path] %>
- <%= link_to row[:link][:text], row[:link][:path], class: "text-indigo-600 hover:underline" %>
- <% else %>
- <%= row[:link][:text] %>
- <% end %>
- <% elsif row[:value] %>
- <%= row[:value] %>
- <% end %>
-
diff --git a/app/views/admin/ahoy_activities/_event_details.html.erb b/app/views/admin/ahoy_activities/_event_details.html.erb
new file mode 100644
index 0000000000..92a6e7d000
--- /dev/null
+++ b/app/views/admin/ahoy_activities/_event_details.html.erb
@@ -0,0 +1,56 @@
+<%# Inline before/after + property rows for one Ahoy event. Shared by the admin
+ activities table's Details column and any record's inline change log, so both
+ read the same way. %>
+<% if activity.extra_details? %>
+
+ <% if activity.changes? %>
+
+ <% activity.changes_summary.each do |change| %>
+
+ <%= change[:field] %>
+
+
+ Before:
+ <%= change[:before] %>
+
+
+ After:
+ <%= change[:after] %>
+
+
+
+ <% end %>
+
+ <% end %>
+ <% activity.detail_rows.each do |row| %>
+
+ <% if row[:label] %>
+ <%= row[:label] %><%= ":" if row[:value] || row[:link] %>
+ <% end %>
+ <% if row[:action] %><%= row[:action] %><% end %>
+ <% if row[:link] %>
+ <% if row[:link][:path] %>
+ <%= link_to row[:link][:text], row[:link][:path], class: "text-indigo-600 hover:underline" %>
+ <% else %>
+ <%= row[:link][:text] %>
+ <% end %>
+ <% elsif row[:change] %>
+