dfaulken
dfaulken

Reputation: 516

How to extend a Ruby library with a helper object

I'm extending Prawn-Rails with PrawnRailsForms. I want those methods to only be available within a block that gives them some context. For example, usage of my extension might look like:

field_row height: 25, units: 8 do
  text_field field: 'Favorite fruit', # ...
end

The way this works under the hood in my library is that the field_row method instantiates a helper object, without which the other methods, like text_field, don't run:

class FieldRow
  # ...
end

class PrawnRails::Document

  attr_accessor :field_row # my helper object

  def field_row(height:, units:, &block)
     @field_row = FieldRow.new #...
     block.call
     @field_row = nil
  end

  def text_field(**args)
    unless @field_row.present?
      raise ArgumentError, 'Must be inside a field row'
    end
    # implementation which relies on @field_row...
  end
end

Is there a nicer way to make sure that my methods are only run when the FieldRow object is available? In particular, having to write @field_row = nil after block.call makes me feel like I'm doing something incorrectly.

Upvotes: 1

Views: 265

Answers (1)

Jordan Running
Jordan Running

Reputation: 106027

Is there a nicer way to make sure that my methods are only run in the proper context, e.g. when the FieldRow object is available?

APIs like this usually use instance_eval so that the methods inside the block are called on the instantiated object:

module PrawnRailsForms
  class FieldRow
    def initialize(height:, units:, **)
      # ...
    end

    def text_field(field:, **)
      # ...
    end
  end

  module Document
    def field_row(**args, &block)
      @field_row = FieldRow.new(**args)
      @field_row.instance_eval(&block) unless block.nil?
      @field_row
    end
  end

  ::PrawnRails::Document.include(Document)
end

Now when you do this:

doc.field_row(height: 25, units: 8) do
  text_field field: 'Favorite fruit'
end

...it will instantiate a FieldRow object with height: and units:, then call text_field on that object.


If you need FieldRow to operate on the document itself, there are a few approaches you could take. One is to just pass self (the document object) to FieldRow's constructor and store it in an instance variable therein for use by text_field et al:

def field_row(**args, &block)
  row = FieldRow.new(self, **args)
  row.instance_eval(&block) unless block.nil?
end

You could also define a method on FieldRow to which you pass the document object. In this scenario, FieldRow's methods (e.g. text_field) "configure" the output and render (or whatever you choose to call it) actually creates the output.

def field_row(**args, &block)
  row = FieldRow.new(**args)
  row.instance_eval(&block) unless block.nil?
  row.render(self)
end

I would caution against adding a bunch of methods to Document. Since text_field only works inside the context of field_row, making text_field an instance method on Document doesn't really make sense (and, as you've discovered, has some gotchas).

Upvotes: 2

Related Questions