phlex-hanami docs usage components.md
9.3 kB · 304 lines
Markdown
at main

Components #

A view is the entry point Hanami calls. A component is everything below it. Subclass Phlex::Hanami::Component and render it with render:

# app/components/card.rb
module MyApp
  module Components
    class Card < Phlex::Hanami::Component
      def initialize(post:) = @post = post

      def view_template
        article do
          h2 { a(href: path(:post, id: @post.id)) { @post.title } }
          yield if block_given?
        end
      end
    end
  end
end
class Index < MyApp::View
  def view_template
    @posts.each { |post| render MyApp::Components::Card.new(post: post) }
  end
end

A component reads the same view context a view does, so path, assets, content_for, flash, session and t all work. Phlex shares that context with every component in the tree, so one nested five levels down still has it.

If you already have a base class of your own, include the module instead:

module MyApp
  class Component < Phlex::HTML
    include Phlex::Hanami::Contextual
  end
end

Phlex::Hanami::Renderable is the view half: the same context, plus a layout and the call contract Hanami's Response#render needs. Contextual is that context on its own.

What a component does not have #

No layout. A layout wraps the view Hanami calls and nothing else, so a component never picks one up and needs no opt out. See Layouts.

No call(context:, **input). Only a view answers that. Phlex's own .call forwards its arguments to new, which is what you want from a component and wrong for a view, so a component cannot be auto rendered by accident.

Where components live #

Under app/components or slices/<slice>/components, matching the namespace. Hanami registers them in the slice container like anything else, so slice["components.card"] returns the class, the same way a view does.

There is no reason to exclude them. Registration is lazy, the key costs nothing, and having it means a component resolves the same way every other class in the slice does. If you would rather they were not registered at all, that is config.no_auto_register_paths, and it is your call rather than the gem's:

# config/app.rb
config.no_auto_register_paths << "components"

Kits #

A kit turns a namespace into a set of methods. Give the module a file and extend it:

# app/components.rb
module MyApp
  module Components
    extend Phlex::Kit
  end
end

Every Phlex class in the namespace now has a method named after it. Include the kit in your base view and call components by name:

module MyApp
  class View < Phlex::Hanami::View
    include MyApp::Components
  end
end
class Index < MyApp::View
  def view_template
    Card(post: @post) { p { "body" } }
  end
end

Kit components can call each other the same way, with no include. Nested modules become kits of their own, so MyApp::Components::Forms::Field works once the parent is extended.

This survives Hanami's autoloading. Phlex resolves a component's constant the first time you call its method, which is what Zeitwerk needs.

Sharing components between slices #

Put them in the app namespace and use them from anywhere:

module Admin
  module Views
    module Posts
      class Index < Admin::View
        def view_template
          render MyApp::Components::Card.new(post: @post)
        end
      end
    end
  end
end

A component takes the context of whoever renders it, not its own slice's, so the card above reads the Admin request's routes, assets and flash. Its own slice only decides one thing: what a relative translation key resolves against.

Components outside a slice #

Hanami::SliceConfigurable finds a slice by matching the class name against each slice namespace. A component named outside all of them, in a gem or a shared library, gets no slice. slice returns nil.

Everything that reads the context still works, because the context comes from the render, not the slice. What you lose is t(".title"), which has nothing to resolve against and raises I18n::ArgumentError. Use an absolute key there.

Props #

A Phlex class collects its props in initialize, and plain keyword arguments are enough:

def initialize(post:, compact: false)
  @post = post
  @compact = compact
end

To declare types and defaults instead, include Phlex::Hanami::Props and type each prop with dry-types:

class Card < Phlex::Hanami::Component
  include Phlex::Hanami::Props

  prop :post, Types::Instance(Post)
  prop :count, Types::Params::Integer
  prop :compact, Types::Bool, default: false
  prop :tags, Types::Array.of(Types::String), default: -> { [] }

  def view_template
    article(class: ("compact" if @compact)) { h2 { @post.title } }
  end
end

Each prop becomes a keyword of initialize and an instance variable of the same name. The gem calls the type with the value, so a dry type coerces as well as checks, and Types::Params::Integer turns "5" into 5. A value the type rejects raises Phlex::Hanami::InvalidPropError, which keeps the type's own error as its cause.

A prop with a default is optional, and so is one whose type has its own, such as Types::Bool.default(false). Defaults go through the type too. Pass a proc for anything mutable, so each instance gets a fresh one. The proc runs on the new instance, so it can read the props declared above it.

A prop whose type accepts nil, such as Types::String.optional, is optional as well, and is nil when left out.

The type can be anything that answers call. Anything that does not, such as a plain class, has to match with ===, so prop :post, Post works too.

The same works in a view. Its initialize declares real keywords, so auto render still drops every param the view does not declare. See Views.

Left out or nil #

prop? declares a prop the caller can leave out. When they do, it holds Phlex::Hanami::Props::UNSET rather than nil, so the class can tell the two apart:

class Form < Phlex::Hanami::Component
  include Phlex::Hanami::Props

  prop? :token, Types::String.optional

  def view_template
    form(method: "post") do
      input(type: "hidden", name: "_csrf_token", value: token) if token
      yield
    end
  end

  private

  # Left out means the session's token. An explicit nil means none.
  def token = Phlex::Hanami::Props::UNSET.equal?(@token) ? csrf_token : @token
end

The type never sees UNSET, only what the caller passes.

Other kinds #

A prop is a keyword unless you give it a kind as the third argument:

class Button < Phlex::Hanami::Component
  include Phlex::Hanami::Props

  prop :label, Types::String, :positional
  prop :variant, Types::Symbol, default: :primary
  prop :attributes, Types::Hash, :**

  def view_template
    button(class: "btn btn-#{@variant}", **@attributes) { @label }
  end
end

render Button.new("Save", type: "submit", data: { turbo: false })

:** gathers every keyword no other prop declares into a Hash, and :* gathers the leftover positional arguments into an Array. The type checks the whole Hash or Array. Neither takes a default: each is empty when nothing is left over. A class has at most one of each.

A view with a :** prop receives every exposure and request param, the same as a hand written **. Keep to keywords in a view, since auto render passes nothing by position.

There is no block kind. Phlex takes the block passed to new for the content, so initialize never sees it.

Coercion #

A block runs on the new instance with the value, before the type sees it:

prop :tags, Types::Array.of(Types::String) do |value|
  value.is_a?(String) ? value.split(",") : value
end

It runs on a default too.

Readers, writers and predicates #

reader:, writer: and predicate: each take :public, :protected or :private and define that method:

prop :post, Types::Instance(Post), reader: :private
prop :compact, Types::Bool, default: false, predicate: :public

A writer runs the value through the type, the same as initialize. Watch the names: a reader called title or p replaces the Phlex element of that name. The :class prop cannot have a reader.

After initialize #

Define after_initialize to run code once every prop is set:

def after_initialize
  @slug = @post.title.downcase.tr(" ", "-")
end

The gem does not depend on dry-types. Add it to your Gemfile, and define Types the way the dry-types docs show.

Literal #

Literal works too, with no help from us. Extend it in place of Phlex::Hanami::Props:

class Card < Phlex::Hanami::Component
  extend Literal::Properties

  prop :post, Post
  prop :compact, _Boolean, default: false
end

Pick one per class. Both define prop.

Testing #

The same helpers as a view. See Testing.

RSpec.describe MyApp::Components::Card, type: :view do
  it "links to the post" do
    expect(render(described_class.new(post: post))).to include(%(<a href="/posts/1">))
  end
end