Change History (PaperTrail)
The paid habarico-enterprise gem ships an optional habarico_enterprise/paper_trail module that adds an object
change-history UI on top of PaperTrail. It
exposes a read-only versions admin resource and renders a per-field diff between consecutive
versions as HTML (<ins> / <del> for inserted / deleted text) in a rich-text field, so the
frontend displays the markup directly.
The diff is shown per tracked field, both as columns in an embedded history list and as an object
detail view. The set of fields is built dynamically from the tracked model's history_fields, so
the schema is specific to the concrete object whose history is shown.
Habarico only provides the admin UI layer. Capturing the snapshots is handled by PaperTrail, so
the consuming app owns model tracking and the versions table migration. The author and
timestamp live directly on each version (whodunnit, created_at); there is no separate
revision/grouping resource.
Installation
The module is not loaded by require "habarico". Its dependencies are optional and are not
declared in the gemspec - add them to your Gemfile:
gem "habarico"
gem "paper_trail"
gem "diff_match_patch"
Run PaperTrail's installer so the versions table exists (with the object_changes column, which
the diff relies on), then opt in, for example in config/initializers/habarico.rb:
bin/rails generate paper_trail:install --with-changes
bin/rails db:migrate
require "habarico_enterprise/paper_trail"
Use jsonb for object and object_changes (PostgreSQL). The per-field diff is computed from
version.changeset, which PaperTrail deserializes from the object_changes column. With the
default text column PaperTrail uses YAML.safe_load, and on Ruby 3.1+ / Psych 4+ it rejects
Time, BigDecimal, Date etc.; PaperTrail then swallows the error and changeset becomes
empty, so the diff silently renders nothing. A jsonb column bypasses YAML entirely
(PaperTrail stores/reads the hash natively). Convert both columns:
class ChangeVersionsObjectColumnsToJsonb < ActiveRecord::Migration[7.2]
def change
change_column :versions, :object, :jsonb, using: "object::jsonb"
change_column :versions, :object_changes, :jsonb, using: "object_changes::jsonb"
end
end
Alternatively, keep the text columns and permit the classes explicitly in config/application.rb:
config.active_record.yaml_column_permitted_classes |=
[Time, Date, DateTime, ActiveSupport::TimeWithZone, ActiveSupport::TimeZone, BigDecimal, Symbol]
Tracking a Model
Mark the models whose history you want to keep with has_paper_trail. Record the acting user via
PaperTrail's controller hook (set_paper_trail_whodunnit) as usual:
class Article < ApplicationRecord
has_paper_trail
end
The Versions Resource
Add a controller that includes both Habarico::Controller and the contrib's VersionController
concern (in that order), and register it in config.resources:
# app/controllers/apps/admin/versions_controller.rb
class Apps::Admin::VersionsController < ApplicationController
include Habarico::Controller
include HabaricoEnterprise::PaperTrail::VersionController
end
# config/initializers/habarico.rb
Habarico.setup do |config|
config.resources = [{ admin: [:articles, :versions] }]
end
This renders a read-only list of versions (id, change time, author, event, type, object id),
filterable by item_type, item_id, event and whodunnit. The single-version detail view adds
a Changes box showing the per-field diff. Both the per-field diff columns and the detail box are
injected dynamically per tracked model, so no Changes column is shown in the plain, cross-type
version list.
History Permissions
A version is only as readable as the resource it tracks. Every request to the versions resource
resolves the tracked resource from the version's item_type (or, for the embedded history list,
from the request _source) and enforces its permission:
can_read_history, when declared on the tracked resource's controller — a boolean or a callable receiving theHabarico::Context, the same contract ascan_read;- the tracked resource's
can_readotherwise.
Version lists are narrowed to the readable item types, and the tracked resource's policy_scope,
scopes and default_filter are applied to the tracked items — history never shows more than the
resource itself would. Versions of models without a registered resource are not readable at all,
and fetching a version outside these permissions returns 404.
class Apps::Admin::ArticlesController < ApplicationController
include Habarico::Controller
model Article
history_fields [:title, :content, :status]
# Everyone with read access sees articles; only auditors may read their change history.
can_read_history ->(context) { context.current_user.auditor? }
end
Selecting History Fields
Declare history_fields on the tracked resource's controller. It lists the fields that are
diffed and shown in the history view. Without it the changes are still recorded, but no per-field
diff is rendered:
class Apps::Admin::ArticlesController < ApplicationController
include Habarico::Controller
model Article
fields [:title, :content, :status]
history_fields [:title, :content, :status]
end
string / text columns are rendered as a character-level diff; other fields are rendered as
old → new. Field labels come from the tracked model's human_attribute_name.
The tracked model is resolved automatically - from the version's item_type (single-version
detail) or from the request _source (embedded list) - through the resource registry, so nothing
has to be registered explicitly.
Embedding History in a Detail View
Use HabaricoEnterprise::PaperTrail.history_list_box inside a show view to embed a record's own
history (a list of its versions, with a per-field diff column for each history field):
Habarico::ShowView.new(
box: Habarico::Box.new(
boxes: [
:title, :content, :status,
HabaricoEnterprise::PaperTrail.history_list_box(
model: Article,
fields: [:title, :content, :status],
),
]
)
)
The box references the versions resource and couples on item_id (the current record's id),
narrowing to the right type via item_type (Article.base_class.name). Each fields entry
becomes a per-field diff column.
Displaying the Author
whodunnit is stored by PaperTrail as a raw string (typically current_user.id). Choose how it is
displayed in an initializer:
# Recommended: render it as a relation (foreign key) to your user resource, so the frontend links
# it and resolves the display name via that resource's `representation`.
HabaricoEnterprise::PaperTrail.whodunnit_reference = :users
# Or resolve it to a plain string label yourself (used only when whodunnit_reference is nil):
HabaricoEnterprise::PaperTrail.whodunnit_label = ->(whodunnit, _version) do
User.find_by(id: whodunnit)&.full_name || whodunnit
end