Skip to main content
Premium

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 the Habarico::Context, the same contract as can_read;
  • the tracked resource's can_read otherwise.

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 &rarr; 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