paper_trail-human
Transforms PaperTrail::Version records into structured, human-readable hashes ready for UI display — audit logs, timelines, activity feeds.
Resolves foreign keys to names, translates enums and constants, formats dates and numbers, and accepts custom transformations via lambda.
Table of Contents
- 1. Introduction
- 2. Configuration
- 3. Resolvers
- 4. Formatting
- 5. Timeline
- 6. I18n
- 7. Architecture
- 8. Requirements
- 9. Contributing
- 10. License
1. Introduction
1a. Compatibility
| paper_trail-human | ruby | activerecord | paper_trail |
|---|---|---|---|
| 0.3.x | >= 3.1 | >= 6.1 | >= 12.0 |
| 0.2.x | >= 3.0 | >= 6.1 | >= 12.0 |
| 0.1.x | >= 2.7 | >= 5.2 | >= 9.0 |
CI matrix (0.3.x):
| Rails | PaperTrail | Ruby |
|---|---|---|
| 6.1 | ~> 12.0 | 3.1, 3.2, 3.3, 3.4 |
| 7.0 | ~> 13.0 | 3.1, 3.2, 3.3, 3.4 |
| 7.1 | ~> 14.0 | 3.1, 3.2, 3.3, 3.4 |
| 7.2 | ~> 15.0 | 3.1, 3.2, 3.3, 3.4 |
| 8.0 | ~> 15.0 | 3.2, 3.3, 3.4 |
1b. Installation
Add to your Gemfile:
gem "paper_trail-human"
Then run:
bundle install
rails generate paper_trail:human:install
The generator creates an initializer at config/initializers/paper_trail_human.rb.
Important: This gem reads from the object_changes column. If your versions table doesn't have it, add it:
rails generate paper_trail:install --with-changes
rails db:migrate
1c. Quick Start
# config/initializers/paper_trail_human.rb
PaperTrail::Human.configure do |config|
config.whodunnit_resolver = ->(id) { User.find_by(id: id)&.name }
end
# Anywhere in your app
PaperTrail::Human.format(version)
# => {
# user: "John",
# event: "update",
# model: "User",
# item_id: 1,
# created_at: 2026-05-29 12:00:00,
# fields: [
# { field: "Name", previous_value: "John", value: "John Smith" },
# { field: "Company", previous_value: "Acme", value: "Globex" }
# ]
# }
2. Configuration
2a. Global Options
PaperTrail::Human.configure do |config|
# Resolve whodunnit IDs to names (default: nil, returns raw ID)
config.whodunnit_resolver = ->(id) { User.find_by(id: id)&.name }
# Fields to exclude from output (default: %w[id created_at updated_at])
config.ignored_fields = %w[id created_at updated_at]
# Custom field name resolver (default: nil, uses I18n then humanize)
config.field_name_resolver = ->(field, model) { ... }
# Translate event names via I18n (default: false)
config.translate_events = true
# Post-processing hook (default: nil)
config.after_format = ->(result, version) { result }
end
2b. Per-Model Fields
PaperTrail::Human.configure do |config|
config.register "User" do |m|
m.field :role, :enum, class_name: "UserRole", method: :label
m.field :company_id, :relation, class_name: "Company", attribute: :name
m.field :active, :boolean, true_label: "Active", false_label: "Inactive"
m.field :bio, :text, max_length: 100, show_diff_stats: true
m.field :due_date, :date, format: "%d/%m/%Y"
m.field :salary, :number, format: :currency, unit: "R$"
m.field :score, :custom, resolve: ->(v) { "#{v} points" }
end
end
2c. Item Name
Adds a human-readable identifier for the record to the output:
config.register "User" do |m|
m.item_name :name
# or with a lambda:
m.item_name ->(version) { "User ##{version.item_id}" }
end
PaperTrail::Human.format(version)[:item_name]
# => "João Silva"
The item_name key is only present when the record exists and the attribute is configured.
2d. After Format Hook
Post-process every formatted result:
config.after_format = ->(result, version) {
result[:record_url] = "/#{result[:model].tableize}/#{result[:item_id]}"
result
}
The lambda receives the formatted hash and the original PaperTrail::Version, and must return the hash.
3. Resolvers
3a. Relation
Resolves a foreign key to an attribute of the associated model.
m.field :company_id, :relation, class_name: "Company", attribute: :name
| Option | Description | Default |
|---|---|---|
class_name: |
The associated model class | required |
attribute: |
Attribute to display | :name |
In batch mode (format_collection), relations are preloaded to prevent N+1 queries.
3b. Enum
Resolves enum values to human labels.
# With a class that responds to a method
m.field :role, :enum, class_name: "UserRole", method: :label
# With a static mapping
m.field :status, :enum, mapping: { "active" => "Active", "inactive" => "Inactive" }
# With Rails native enum
m.field :role, :enum, from_model: "User"
m.field :role, :enum, from_model: "User", labels: { admin: "Administrator" }
| Option | Description |
|---|---|
class_name: + method: |
Calls ClassName.method(value) |
mapping: |
Static hash lookup |
from_model: |
Reads from Model.defined_enums |
labels: |
Custom labels for from_model |
3c. Boolean
Custom labels for boolean fields:
m.field :active, :boolean, true_label: "Active", false_label: "Inactive"
3d. Custom
Arbitrary transformation via lambda:
m.field :score, :custom, resolve: ->(value) { "#{value} points" }
3e. Text
Truncates long text fields:
m.field :body, :text, max_length: 100, show_diff_stats: true
# => "Lorem ipsum dolor sit amet..." (250 chars)
| Option | Description | Default |
|---|---|---|
max_length: |
Maximum characters before truncation | 80 |
show_diff_stats: |
Append total char count | false |
3f. Date
Formats date/time values:
m.field :due_date, :date, format: "%d/%m/%Y"
# => "30/05/2026"
| Option | Description | Default |
|---|---|---|
format: |
strftime format string |
"%Y-%m-%d" |
Accepts Date, Time, DateTime, and parseable strings.
3g. Number
Formats numeric values:
m.field :amount, :number, format: :currency, unit: "R$"
# => "R$ 1,500.99"
m.field :rate, :number, format: :percentage
# => "85.50%"
| Option | Description | Default |
|---|---|---|
format: |
:default, :currency, :percentage |
:default |
unit: |
Currency symbol (for :currency) |
nil |
precision: |
Decimal places | 2 |
delimiter: |
Thousands separator | "," |
separator: |
Decimal separator | "." |
4. Formatting
4a. Single Version
PaperTrail::Human.format(version)
Returns a hash with keys: user, event, model, item_id, created_at, fields, and optionally item_name.
Event-specific behavior:
- create: fields omit
previous_value - update: fields include both
previous_valueandvalue - destroy: fields omit
value
4b. Collection
PaperTrail::Human.format_collection(user.versions)
Same as format but for multiple versions. Relations are batch-loaded to prevent N+1 queries.
4c. Filtering Fields
PaperTrail::Human.format(version, only: [:name, :email])
PaperTrail::Human.format(version, except: [:password_digest])
4d. Output Formats
By default, methods return hashes. Use as: for string output:
PaperTrail::Human.format(version, as: :text)
# => "Updated User#1 by John at 2026-05-30\n • Name: Old → New"
PaperTrail::Human.format(version, as: :markdown)
# => Markdown with header and table
PaperTrail::Human.format(version, as: :html)
# => HTML div with table (XSS-safe, escapes entities)
Available formats: :text, :markdown, :html.
Works with both format and format_collection.
5. Timeline
Group versions by time period:
PaperTrail::Human.timeline(user.versions, group_by: :day)
# => {
# "2026-05-28" => [{ user: ..., fields: [...] }, ...],
# "2026-05-30" => [{ user: ..., fields: [...] }]
# }
group_by |
Format | Example |
|---|---|---|
:day |
%Y-%m-%d |
"2026-05-30" |
:week |
%G-W%V |
"2026-W22" |
:month |
%Y-%m |
"2026-05" |
:year |
%Y |
"2026" |
Supports only: and except: filters.
6. I18n
6a. Field Names
Field names are resolved in this order:
- Custom
field_name_resolverlambda (if configured) I18n.t("activerecord.attributes.model_name.field_name")(if I18n available)- Automatic humanization (removes
_idsuffix, titleizes)
Example: company_id → looks up activerecord.attributes.user.company_id → falls back to "Company".
6b. Event Labels
Enable translated event labels:
config.translate_events = true
The gem includes locale files for en and pt-BR. Add your own:
# config/locales/paper_trail_human.en.yml
en:
paper_trail_human:
events:
create: "Created"
update: "Updated"
destroy: "Destroyed"
# config/locales/paper_trail_human.pt-BR.yml
pt-BR:
paper_trail_human:
events:
create: "Criação"
update: "Atualização"
destroy: "Exclusão"
7. Architecture
Hexagonal (Ports & Adapters):
┌─────────────────────────────────────────────┐
│ Core │
│ ChangeExtractor · FieldFormatter │
│ EventTranslator · Presenter │
│ BatchPresenter · Timeline │
├─────────────────────────────────────────────┤
│ Ports │
│ Resolver (interface) │
├─────────────────────────────────────────────┤
│ Adapters │
│ Resolvers: Relation, Enum, Boolean, │
│ Custom, Text, Date, Number │
│ Formatters: Text, Markdown, Html │
└─────────────────────────────────────────────┘
- Core — pure formatting logic, no external dependencies
- Ports —
Resolverinterface that every adapter implements - Adapters — concrete implementations for resolving values and formatting output
The gem has zero dependencies beyond activerecord and paper_trail. The Railtie is optional — it works in non-Rails apps (Sinatra, Hanami, etc).
8. Requirements
- Ruby >= 3.1
- Rails >= 6.1 (or standalone ActiveRecord)
- PaperTrail >= 12.0
9. Contributing
See CONTRIBUTING.md for guidelines on setting up the development environment, running tests, and submitting pull requests.
10. License
MIT. See LICENSE.txt.