Skip to content

Record entity history

Journals are the comments and changes of an entity. They are stored in the journals table, with a journal_details record for each changed attribute. Easy8 shows them as the history on the detail page of an entity.

The history shows EasySetting.value("easy_extensions_journal_history_limit") journals, 10 by default. A "Show all" button at the bottom loads the rest.

Model

Add acts_as_easy_journalized to the model. It adds the journals association and the methods that store changes. The options are documented in ActsAsEasyJournalized#acts_as_easy_journalized.

Example of acts_as_easy_journalized usage
1
2
3
4
5
6
7
8
module EasyModule
  class MyModel < ApplicationRecord
    include Redmine::SafeAttributes
    acts_as_easy_journalized non_journalized_columns: %w[uuid author_id]

    after_save :create_journal
  end
end

To journalize attribute changes, call init_journal before you set the attributes. save then creates the JournalDetail records.

Formatting of journal details

EasyJournalHelper#show_easy_journal_detail renders each JournalDetail as "Label changed from old to new" by default. Columns listed in the format_detail_* options of acts_as_easy_journalized get a dedicated format:

Option Rendering
format_detail_date_columns values formatted with format_date
format_detail_time_columns values formatted with format_time
format_detail_reflection_columns *_id columns resolved to the name of the associated record
format_detail_boolean_columns 0/1 rendered as Yes/No
format_detail_hours_columns values formatted with easy_format_hours
format_detail_enum_columns values resolved through human_enum_name
format_detail_diff_columns long texts rendered as "Label updated (diff)" with a link to the word diff
format_detail_hash_diff_columns json/hash columns rendered as a git-like key diff, see below

Hash and array values of columns listed in format_detail_hash_diff_columns are stored in journal_details as JSON (Journal#add_attribute_detail). Hash details render only the added, removed and changed keys:

Example of hash diff column
acts_as_easy_journalized format_detail_hash_diff_columns: %w[env_variables]
Rendered detail
Env variables updated
-SMTP_PORT=2525
+SMTP_PORT=25
+SMTP_HOST=172.25.11.42

Non-string values and multiline strings (including keys) are serialised as JSON. Array details fall back to the default "changed from/to" rendering.

Controller

Call init_journal in the update action. Show the history from a separate journals action that renders the journals through AJAX.

Example of update action
def update
  my_model.init_journal(User.current, params[:notes])
  my_model.safe_attributes = params[:my_model]
  respond_to do |format|
    format.html do
      if my_model.save
        flash[:notice] = l(:notice_successful_update)
        redirect_back_or_default my_model_path(my_model)
      else
        render action: :edit
      end
    end
  end
end

Include the EasyControllersConcerns::EasyJournals concern in your controller and define the journals method as below.

Example of journals action for render history
1
2
3
4
5
include EasyControllersConcerns::EasyJournals

def journals
  render_easy_journals(my_model)
end

Users permissions

Add the journals action to the view_ permission of your plugin:

Example of permissions
permission :view_my_models, { my_models: %i[index show journals] }

Routes

Define a route for the journals action. For example:

Example of routes
1
2
3
4
5
resources :my_models do
  member do
    get :journals
  end
end

Views

Async loading using tabs

Render the tabs partial:

<div id="entity-detail-bottom">
  <%= render partial: "common/entity_tabs", locals: { tabs: my_model_tabs(@my_model), tabs_container: "entity-detail-bottom" } %>
</div>

Define a helper method that returns the array of tabs, with a trigger that loads the history through AJAX:

Example of helper method for tabs
module MyModelHelper

  def my_model_tabs(entity)
    tabs = []
    tabs << { name: "history", label: l(:label_history), trigger: "EntityTabs.showAjaxTab(this, '#{journals_my_models_path(my_model, tab: 'history')}')" }

    call_hook(:helper_my_model_tabs, tabs:, entity:)

    tabs
  end

end

Pagination

EasyExtensions::JournalsTabsManager accepts page: and per_page:. With per_page, journals are sliced from the newest one and the history shows a "Load more" link instead of "Show all". JavaScript (EASY.utils.easyJournalLoadMoreHistory) fetches the next page and inserts older journals above the list, or below it when the user prefers comments in reverse order.

Example of paginated journals action
1
2
3
4
def journals
  tab_manager = EasyExtensions::JournalsTabsManager.new(my_model, tab: params[:tab], page: params[:page], per_page: 25)
  render tab_manager, as: :tab_manager
end

Subclass the manager and override journal_scope to change which journals a tab shows. Pass the subclass to render_easy_journals(entity, tab_manager_class: MyTabsManager).

User reactions

Users can react to comments with emojis. To initialise the reaction components for journals loaded after the first page ("Show all"), define a GraphQL Type and a Query field for your entity.

In the Type, call has_journals. You can override the methods it adds.

Example of journals usage in GraphQL
1
2
3
4
5
6
7
8
module EasyGraphql
  module Types
    class MyModel < Base
      has_journals

    end
  end
end

Then extend Query with a field that finds the entity:

Example of extending EasyGraphql::Types::Query
EasyGraphql.register_patch("EasyGraphql::Types::Query") do
  field :my_model, EasyGraphql::Types::MyModel, null: true do
    description "Find MyModel by ID"
    argument :id, GraphQL::Types::ID, required: true
  end

  def my_model(id:)
    MyModel.visible.find_by(id:)
  end
end

Issue email notifications

Issue journals are not mailed one by one. Every journal that passes Journal#send_email_notifications? creates one EasyMailQueue row (table easy_mail_queue). ProcessMailQueueJob runs from Sidekiq cron (default */15 * * * *, override with PROCESS_MAIL_QUEUE_CRON_INTERVAL), claims all rows (loads, then deletes them) and hands them to ProcessMailQueue.

ProcessMailQueue groups the journals by issue and builds one e-mail per recipient:

  • Recipients come from Issue#get_notified_users_for_issue_edit(journal) for each journal, so a user only gets the journals they may see (private notes, visible details).
  • The author's own journals are skipped for users with no_self_notified.
  • attr and cf details are squashed to the latest change per attribute; every other detail (relations, attachments) is kept.
  • EmailNotificationsMailer#issue_changes renders the journals grouped by author, with Message-ID from the first journal and References on the issue.

No journal is queued for issues in template or planned projects, for journals with notify == false, or when the author has no_notification_ever. Other journalized entities (for example CRM cases) do not use the queue. Mails such as issue_add, mentions or reminders are sent immediately by their mailers.

Notification centre rows are described in Notification centre.