The letter A styled as Alchemists logo. lchemists
Published September 1, 2023 Updated September 11, 2023
Cover
Hanami Views

At the moment, the Hanami View gem is in a prerelease state but I’ve been making heavy use of this gem so wanted to share notes in case this information is helpful to you or, at a minimum, prep you for the upcoming 2.1.0 release. This article assume you have familiarity with Hanami but, if you get lost, you can refer to the Hanami Guides to get up to speed.

History

The Hanami View gem is an evolution of work originally started with the Dry View gem. I’ll be referencing Dry View since some of the documentation still applies for Hanami View.

You want to use Hanami View instead of Dry View since the latter has been eclipsed by the former because the Dry View gem hasn’t been actively worked on for some time. In fact, the last release is a couple years old now.

Setup

You might find the Hanamismith gem useful since it automates generating a Hanami application for you with Hanami View included despite being in a prerelease state. For example, Hanamismith configures your Gemfile to point to Hanami View main branch:

gem "hanami-view", github: "hanami/view", branch: "main"

Once Hanami 2.1.0 is released, Hanamismith will be updated to use the 2.1.0 version of all related gems. I’ll be referring to Hanamismith throughout this article since this gem gives you a professional setup for local use.

Quick Start

A quick way to get started is to use Hanamismith to build a Hanami application for you:

gem install hanamismith
hanamismith build --name demo

Then, using a tool like exa, you can print a tree view of the home slice as shown here:

Home slice

The above provides a great entry point for learning the basics of Hanami View by starting from the outside and working deeper into the view layer. We’ll start with the routes (the only file not shown above):

# demo/config/routes.rb
module Demo
  class Routes < Hanami::Routes
    slice(:home, at: "/") { root to: "show" }
  end
end

Notice that any request made to / directs traffic to the show action of our home slice. If we look at the Show action, we see:

# demo/slices/home/actions/show.rb
module Home
  module Actions
    class Show < Home::Action
    end
  end
end

By default, an action will immediately render the associated view of the same name as shown here:

# demo/slices/home/views/show.rb
module Home
  module Views
    class Show < Home::View
      expose :ruby_version, default: RUBY_VERSION
      expose :hanami_version, default: Hanami::VERSION
    end
  end
end

You’ll notice the Show view exposes data for rendering within the template of the same name which is shown here but truncated for brevity:

<!-- demo/slices/home/templates/show.html.erb -->
<footer class="footer">
  <ul class="group">
    <li>Ruby <%= ruby_version %></li>
    <li>Hanami <%= hanami_version %></li>
  </ul>
</footer>

When we launch the demo application and view the home page you can see the route, action, view, and template render the following page:

Home page

That’s quite nice for minimal code and, look, the Ruby and Hanami version information that was exposed via our Show view can be seen in the page footer!

The rest of this article will delve into the full Hanami View feature set so, without further ado, let’s dive in!

Views

Views are your top level domain objects which provide a complete standalone rendering system that can be composed of several components for greater separation of concerns:

  • Configuration

  • Contexts

  • Parts

  • Helpers

  • Templates and Partials

  • Scopes

We’ll dive into each of the above but, first, I want to demonstrate the power of Hanami View when used in isolation. To begin, each view must inherit from Hanami::View and have an associated template. Here’s a standalone example — a plain old Ruby object leveraging the Command Pattern — where a default label is injected and exposed for rendering within a template:

#! /usr/bin/env ruby
# frozen_string_literal: true

# Save as `demo`, then `chmod 755 demo`, and run as `./demo`.

require "bundler/inline"

gemfile true do
  source "https://rubygems.org"
  gem "hanami-view", github: "hanami/view", branch: "main"
end

require "pathname"

Pathname("show.html.erb").write("<h1><%= label %></h1>")

module Demo
  module Views
    class Show < Hanami::View
      config.paths = Pathname.pwd
      config.template = "show"

      def initialize(label: "Demo", **)
        @label = label
        super(**)
      end

      expose(:label) { label }

      private

      attr_reader :label
    end
  end
end

puts Demo::Views::Show.new.call
# <h1>Demo</h1>

When unpacking the above, you’ll notice I provide a minimal implementation which:

  1. Uses a show.html.erb template that renders the exposed label provided by the view.

  2. Uses a Show view which is configured to use the current working directory to look for the associated show.html.erb template.

  3. Uses a label dependency which defaults to "Demo" and is exposed for rendering within the show.html.erb template.

When the view is initialized and called, we see <h1>Demo</h1> is rendered. This makes views extremely versatile, simple to test in isolation, and great for use outside of a Hanami application. 🚀

Exposures

Exposures allow the passing of values — either via injected dependencies or inputs upon construction — from the view to the templates. Exposures are meant for exposing data only, not behavior. For example, routes can be exposed as follows:

class Index < Hanami::View
  include Deps[:routes]

  expose(:tasks_path) { routes.path :tasks }
end

Exposures can have defaults:

class Index < Hanami::View
  include Deps[:routes]

  expose :tasks_path, default: routes.path(:tasks)
end

Defaults can be taken a step further by defining keyword parameters:

# Index pagination that defaults to the first page with a 20 record limit.
class Index < Hanami::View
  include Deps[repository: "repositories.task"]

  expose(:tasks) { |page: 1, limit: 20| repository.paginate page, limit: }
end

# Show a task by ID.
class Show < Hanami::View
  include Deps[repository: "repositories.task"]

  expose(:task) { |id:| repository.find id }
end

Exposures can be used within your actions so, given the above, you can supply custom data as keyword arguments to your view when rendered by the action:

class Show < Hanami::Action
  def handle request, response
    # Implicit, default behavior:
    # response.render view

    # ...but you can do this instead:
    # response[:tasks_path] = "/my/tasks/path"

    # ...or this:
    # response.render view, tasks_path: "/my/tasks/path"

    # The latter two examples are equivalent.
  end
end

An exposure can depend on another by making the dependent exposure a positional argument:

class Show < Hanami::View
  include Deps["repositories.books", "repositories.authors"]

  expose(:book) { |id:| books.find id }
  expose(:author) { |book| authors.find book.author_id }
end

Exposures can be private which is handy when you need building blocks — not needed by the template — which are great for use in complex exposures:

class Show < Hanami::View
  include Deps[repository: "repositories.task"]

  expose(:user_name) { |user, id:| user(id:).name }

  private_expose(:user) { |id:| repository.find id }
end

By default, exposures are only available to the template but can be made available to the layout as well by using layout: true:

class Index < Hanami::View
  expose :users, default: [], layout: true
end

Exposures are decorated by parts but this can be disabled, via decorate: false, since this allows you to use a primitive which has no extra behavior:

class Show < Hanami::View
  expose :label, default: "Demo", decorate: false
end

Debugging

Views are convenient to use outside of HTTP requests/responses which simplifies debugging and testing. For example — since every view is a registered dependency — you can access a view instance via the application container as follows:

Home::Slice["views.show"].call

The above would render the following output (truncated for brevity):

<html>
  <body>
    <h1>Demo</h1>
  </body>
</html>

You can take this a step further. In this case, I inspect the view’s locals:

Home::Slice["views.show"].call.locals

# {
#   ruby_version: #<Home::Views::Part name=:ruby_version value="3.2.2">,
#   hanami_version: #<Home::Views::Part name=:hanami_version value="2.1.0.beta1">
# }

Testing

RSpec.describe Home::Views::Show do
  subject(:view) { described_class.new }

  describe "#call" do
    it "renders heading" do
      expect(view.call.to_s).to include("<h1>Demo</h1>")
    end
  end
end

Configuration

Views are highly customizable — most of which are preconfigured for you when used within a Hanami application — but you can use a custom configuration instead of the defaults. Here’s an example of what you can customize:

class Show < Hanami::View
  # Defines the context to be used within views, parts, scopes, templates, and partials.
  config.default_context = Alternative.new

  # Defines a custom layout.
  config.layout = "alternative"

  # Defines a custom builder.
  config.part_builder = Alternative

  # Defines a custom namespace.
  config.part_namespace = Alternative

  # Defines custom paths.
  config.paths = Hanami.app.root.join "alternative/templates"

  # Defines custom template.
  config.template = "alternative"
end

For more on the above, I’ll refer you to the Dry View Configuration documentation.

Use of a custom configuration emphasizes my earlier example where I show how you can use Hanami View outside of a Hanami application. This is so powerful that I’m using Hanami View to rebuild my Milestoner gem for building release notes. Even better, this works extremely well with an XDG configuration which I’m leveraging to great effect in my Milestoner work.

I do want to note that, at the moment, use of config.paths has a set and forget behavior because you can’t append paths to it without extra effort. To do this, you need to create a Hanami::View::Path object which can be appended to your path. Like so:

config.paths.append Hanami::View::Path[Pathname(__dir__).join("templates").expand_path]

The above is necessary because only view paths are allowed at the moment. I point this out because knowing how to append to an existing path — especially when using a slice — allows you to provide fallback behavior that might not have custom templates, parts, etc.

Contexts

Contexts are meant for passing common data between views, parts, scopes, helpers, and templates. For example: current user or asset paths. Each context must inherit from Hanami::View::Context and doesn’t need to be auto-registered because Hanami will look for the Context class instead. Each context has access to additional data such as request, csrf_token, and much more. Here’s a simple example with no customization:

# demo/views/context.rb
# auto_register: false

module Demo
  module Views
    class Context < Hanami::View::Context
    end
  end
end

Contexts are designed to be injected with dependencies. Example:

class DemoContext < Hanami::View::Context
  def initialize(title:, description:, **)
    @title = title
    @description = description
    super(**)
  end
end

Contexts can be decorated with parts:

class DemoContext < Hanami::View::Context
  decorate :navigation

  attr_reader :navigation

  def initialize(navigation:, **)
    @navigation = navigation
    super(**)
  end
end

You can pass the same options to decorate as you do with exposures, for example:

class DemoContext < Hanami::View::Context
  decorate :navigation, as: :menu
end

Finally, contexts can be passed in when calling the view:

view.call context:

Parts

Parts are the presentation layer and should be familiar territory for those familiar with the presenter/decorator pattern. By default, parts need to be a sub-directory within the views directory (i.e. views/parts) and will be auto-loaded for you.

You can define a root part via views/part.rb which inherits from your slice. This allows you to customize your part for the slice much like how view.rb and action.rb work at the slice root.

Using the Hemo application, for example, here is a task part which provide additional information when rendered by the view:

module Tasks
  module Views
    module Parts
      class Task < Hanami::View::Part
        def assignee = user.name

        def checked = ("checked" if completed_at)

        def css_class = completed_at ? "task task-completed" : "task"
      end
    end
  end
end

The corresponding spec is:

# frozen_string_literal: true

require "hanami_helper"

RSpec.describe Tasks::Views::Parts::Task do
  subject(:part) { described_class.new value: task }

  let(:task) { Test::Factory.structs[:task, id: 1, user:] }
  let(:user) { Test::Factory.structs[:user, name: "Jane Doe"] }

  describe "#assignee" do
    it "answers user" do
      expect(part.assignee).to eq("Jane Doe")
    end
  end

  describe "#checked" do
    it "answers checked when completed" do
      allow(task).to receive(:completed_at).and_return(Time.now.utc)
      expect(part.checked).to eq("checked")
    end

    it "answers nil when not completed" do
      expect(part.checked).to be(nil)
    end
  end

  describe "#css_class" do
    it "answers completed when completed" do
      allow(task).to receive(:completed_at).and_return(Time.now.utc)
      expect(part.css_class).to eq("task task-completed")
    end

    it "answers default class when not completed" do
      expect(part.css_class).to eq("task")
    end
  end
end

Other than a tiny bit of setup where I use a user and task in-memory ROM factory, I then pass the task record as a value to the part and that’s it. Simple! ⚠️ That said, ensure you only use method names that don’t override the default value method provided for you so you can access the original object injected into your part.

⚠️ You can’t use helpers in parts without a monkey patch. To resolve, use:

class Demo < Hanami::View::Helpers::TagHelper
  include Hanami::View::Helpers::TagHelper

  private

  # TODO: Remove once fixed in Hanami View.
  def tag_builder_inflector = Dry::Inflector.new
end

Despite pointing out the above, monkey patches or — more specifically: overrides — are one of many Ruby Antipatterns to avoid. Additionally — because parts are the presentation layer  — you should avoid mixing template logic within your parts. Instead, keep your parts solely focused on rendering the data so they can be used with multiple templates as desired. Reusability maximization always yields excellent dividends.

Scopes

Scopes are an evolution of template systems, like ERB, which accept a string template and a binding context. Scopes provide a formalized way of customizing your binding context when the template renders and determines which methods are available for use within a template. A few notes:

  • By default, scopes need to be a sub-directory of views (i.e. views/scopes) to be auto-loaded for you.

  • Scopes can be namespaced (i.e. config.scope_namespace = View::Scopes::Demo) by defining the namespace and then adding scopes to that namespace (i.e. views/scopes/demo/example.rb).

  • Only one namespace can be associated with a view at a time since multiple namespaces are not supported. In order to share functionality, you have to subclass your scopes which carries the same caveats as with standard inheritance so keep your behavior well encapsulated.

  • When you are inside a template, self is an instance of a scope.

  • Scopes have access to global helpers.

When used outside of Hanami, the default folder structure is not auto-detected so you must be explicit:

class Show < Demo::View
  # Equates to `views/scopes`.
  config.scope_namespace = Demo::Views::Scopes
end

Here’s an example of an implementation and corresponding spec:

Implementation

# frozen_string_literal: true

require "refinements/arrays"

module Main
  module Views
    module Scopes
      # Encapsulates the rendering of a task description input with possible error.
      class Description < Hanami::View::Scope
        using Refinements::Arrays

        def value = content

        def message = (error[:description].to_sentence if error.key? :description)
      end
    end
  end
end

Spec

# frozen_string_literal: true

require "hanami_helper"

RSpec.describe Main::Views::Scopes::Description do
  subject(:scope) { described_class.new locals:, rendering: view.new.rendering }

  let(:locals) { {content: "Test", error: {description: ["Danger!"]}} }

  let :view do
    Class.new Hanami::View do
      config.paths = SPEC_ROOT
      config.template = "n/a"
    end
  end

  describe "#content" do
    it "answers content" do
      expect(scope.content).to eq("Test")
    end
  end

  describe "#message" do
    it "answers error message when error description exists" do
      expect(scope.message).to eq("Danger!")
    end

    it "answers nil when error description doesn't exist" do
      locals[:error] = {}
      expect(scope.message).to be(nil)
    end
  end
end

Helpers

Helpers are where you define all of your custom tags for use within your views, parts, scopes, templates, and partials. Example:

module Demo
  module Views
    module Helpers
      def warning
        tag.span class: :warning do
          # Implicit yielding allows you to captures content from the block for use in the template.
          yield
        end
      end

      def error(message) = tag.span message, class: :errr
    end
  end
end

Helpers are available everywhere: views, parts, scopes, templates, partials, etc. That said, including them within your views, parts, scopes, etc is not a good idea because of this bug. In truth, this is more of a feature than a bug because mixing helpers within your views and parts prevents them from being reused in different formats (i.e. html, rss, json, etc.) — as emphasized earlier — and you don’t want to miss out on that flexibility.

By default, any slices/<name>/views/helper.rb will be automatically included for use. Example:

module Tasks
  module Views
    module Helpers
    end
  end
end

To add additional helpers, you could use this structure within your slice:

slices/<slice>/views/helper.rb
slices/<slice>/views/helpers/one.rb
slices/<slice>/views/helpers/two.rb

For each new helper module you add, you’ll need to manually include them as you would any Ruby module. There is nothing special about helpers, they are normal Ruby modules so, once included, ensure you don’t have method name conflicts with other modules.

⚠️ Helpers have access to the routes method so ensure you don’t override this.

The following is is not a helper but I’m mentioning it here because it acts like one and allows you to define content in a template for use in another template later. This is powerful when combined with your layout which allows your template to reach up into the layout and customize as necessary. This behavior is very similar to behavior found in other frameworks. Example:

# slices/home/templates/layouts/app.html.erb
<%= content_for :title %>

# slices/home/templates/show.html.erb
<% content_for :title, "Demo" %>

Layouts

Layouts provide a global format for all templates to be embedded within and are enabled by default. In situations where you don’t need a layout, you can pass layout: false when rendering the view. For example, here’s a show action which renders a response where the layout is disabled for the view:

class Show < Home::Action
  def handle(*, response) = response.render view, layout: false
end

At the moment, layouts can only be defined per slice. This is good for separation of concerns but means there isn’t a great way to have a shared theme or common set of functionality that could be inherited and overwritten by each slice. A workaround is to do this:

# demo/slices/home/view.rb
module Home
  class View < Demo::View
    config.paths = [
      # Provides global application fallbacks.
      Hanami.app.root.join("app/templates"),

      # Use slice specific templates when found, otherwise use application fallbacks.
      Pathname(__dir__).join("templates").expand_path
    ]
  end
end

As mentioned earlier, you can’t append to config.paths unless you use a Hanami::View::Path object.

Templates and Partials

Templates allow you to consume data exposed via your view for rendering via your template engine (i.e. ERB). They can be broken down further into partials for greater reusability. A template can render a partial in multiple ways. The following demonstrates usage:

# Preferred. Uses `task` as the partial object.
<% tasks.each do |task| %>
  <%= task.render :task %>
<% end %>

# Renames `task` as `next_action` for use in the partial.
<% tasks.each do |task| %>
  <%= task.render :task, as: :next_action %>
<% end %>

# Passes `label` as additional data to the partial.
<% tasks.each do |task| %>
  <%= task.render :task, label: "Test" %>
<% end %>

Escaping

You can escape multiple ways:

<%== demo %>
<%== demo.html_safe %>
<%= raw demo %>

None of the above are recommended because of security vulnerabilities and general Ruby Antipatterns. Instead, you should use the Sanitize to ensure all output is properly sanitized.

Conclusion

I hope you’ve enjoyed this look at the capabilities of the Hanami View gem. If you’d like additional examples to further your learning, I’d recommend the following:

  • Hemo: A Hanami/htmx demonstration application which was initially generated using the Hanamismith gem.

  • Milestoner: A release notes and deployment automation gem that is currently getting a major overhaul to leverage the Hanami View gem in a standalone capacity. I’m looking forward to officially releasing this work in the next major version but am currently dependent upon the Hanami 2.1.0 release.

  • Rocky Mountain Ruby: The upcoming conference in Boulder, Colorado where I’m one of the main speakers who will be presenting on some of what you’ve seen here.