Cells - Introduction
Tags: ruby, rails, cells
Published: 2021-03-12 07:59:55

## Background
GitHub has recently posted an article about [`view_component`](https://viewcomponent.org):
https://github.blog/2020-12-15-encapsulating-ruby-on-rails-views/
Before it gets too popular I think I should share my experience with [`cells`](https://github.com/trailblazer/cells)  
So that developers can have another chance to re-think and pick what to use for "encapsulated view components".

## Scope of this post

(1) Only ["concept cells"](https://github.com/trailblazer/cells/tree/v4.0.5#concept-cells), not `view_component` style cells. 
The "default"/`view_component` naming style makes it difficult to just copy & rename a folder to bootstrap a new view component  

(2) File Structure, Inputs, Rendering, View Inheritance & Caching 
Testing - I am too lazy to do it (rarely beneficial for me) 
Layout - Never used it.  

### File Structure

(Modified from [cells old README]((https://github.com/trailblazer/cells/tree/v4.0.5#concept-cells)))
(Remember this is for concept cells)
```
app
├── concepts
│   ├── copyable_url_box
│   │   ├── cell.rb
│   │   ├── views
│   │   │   ├── show.haml
```

**File content**
```ruby
class CopyableUrlBox::Cell < Cell::Concept
  # ..
end
```

If you want to put these files into a non-default folder like `app/resources`
You need to update the `view_paths`
```ruby
class CopyableUrlBox::Cell < Cell::Concept
  self.view_paths = Array("app/resources") + view_paths
  # ..
end
```

### Inputs

The [official doc](https://github.com/trailblazer/cells/tree/v4.0.5#concept-cells) suggests using this:
```ruby
concept("comment/cell", @comment) #=> return a cell
```

This gives you access to an attribute `model` (which is `@comment`) inside the cell object by default

But I prefer passing a hash so I do this:
```ruby
class ApplicationConceptCell < Cell::Concept
  module Cells3HashInputBehaviourReplication
    # Since in cells 3.x
    # When a hash is passed in
    # Everything in hash is injected as ivars
    # So we want to replicate the behaviour here
    #
    # This override works on cells 4.1.5
    def initialize(model, options={})
      if model.is_a?(::Hash)
        model.each_pair do |key, value|
          instance_variable_set("@#{key}", value)
        end

        # To avoid ivar `@model` being set in super with the Hash
        # We need to fetch value from hash and put it into `super`
        # Even usually it's `nil`
        super(model.fetch(:model) { nil }, options)
      else
        super
      end
    end
  end
  prepend Cells3HashInputBehaviourReplication
end

# Remember you have to inherit from the right cell!
class CopyableUrlBox::Cell < ApplicationConceptCell
  private

  # region Inputs
    
  # Contract Something
  # attr_reader :input_name
    
  Contract And[::String, Send[:present?]]
  attr_reader :url
    
  # endregion Inputs
end
```

This gives me access to input values as long as I defined them via `attr_reader`. 
Oh what's the `Contract XXX` above `attr_reader`? 
They are from [contracts.ruby](https://github.com/egonSchiele/contracts.ruby) and completely optional and won't be explained in this post. 
You can safely ignore those and maybe study that gem later. 

I am also using another way to handle inputs which is to put input values into `inputs` attribute via a value object. 
```ruby
require "contracted_value"

class ApplicationConceptCell2222 < Cell::Concept
  module HashInputsAsImmutableInputs
    def initialize(model, options={})
      if model.is_a?(::Hash)
        @inputs = self.class.inputs_class.new(model)

        # To avoid ivar `@model` being set in super with the Hash
        # We need to fetch value from hash and put it into `super`
        # Even usually it's `nil`
        super(model.fetch(:model) { nil }, options)
      else
        super
      end
    end

    private

    attr_reader :inputs
  end
  prepend HashInputsAsImmutableInputs
  class_attribute(
    :inputs_class,
    instance_reader: false,
    instance_writer: false,
  )
  class << self
    def define_inputs(&block)
      new_inputs_class = Class.new(::ContractedValue::Value)
      new_inputs_class.include(::Contracts::Core)
      new_inputs_class.include(::Contracts::Builtin)
      new_inputs_class.class_eval(&block)

      self.inputs_class = new_inputs_class
    end
  end
end

# Remember you have to inherit from the right cell!
class CopyableUrlBox2222::Cell < ApplicationConceptCell2222
  private

  # region Inputs

  define_inputs do
    attribute(
      :url,
      contract: And[::String, Send[:present?]],
    )
  end

  # endregion Inputs
end
```
Above implemented via my own gem [`contracted_value`](https://github.com/PikachuEXE/contracted_value) and feel free to swap it with any other value object implementation which can be gems or your own code.

### Rendering

To use a cell, by default just invoke `#call`  
And you can do it by:
- Get result string directly
- Create a cell, do something in the middle, get result string later (no need to invoke `#call` right after cell creation, and feel free to add new public methods as your own API)  
```ruby
# controller
render(
  html: concept("copyable_url_box/cell, url: "...").call
)
```

Since I use `cells-rails`, `#call` returns HTML safe string. 
If you don't use `cells-rails` you might need to do extra work or include extra module to make that happen (check doc yourself). 

In cell class, it looks like this:
```ruby
class CopyableUrlBox::Cell < ApplicationConceptCell
  # This is actually optional if you use >= 4.1
  # BUT you can make this return something else
  def show
    render
  end

  # inputs and other stuff...
end
```

Why `#show`? If you read the doc you will know that 
`concept_cell.call == concept_cell.call(:show)` 
You are free to use other names but not recommended. 

The templates can access **anything** inside the cell. 
Yes this includes
- the inputs (yes even they are private attributes)
- any public/protected/private methods
- Some helper methods are delegated to controller if `cells-rails` is used
- Extra helper methods from helper modules included into the cell

No more "locals" when rendering. What you see (in cell objects) is what you get (in templates).

Having template file(s) is optional. 
Returning a tag directly in `#show` also works. 
```ruby
class WebsiteLogo::Cell < ApplicationConceptCell
  def show
    image_tag(
      website_logo_image_path,
      alt: "Logo",
      class: "...",
    )
  end

  private

  def website_logo_image_path
    case inputs.variant
    when :small
      "..."
    else
      "..."
    end
  end

  # inputs and other stuff...
end
```

### View Inheritance

I generally prefer composition over inheritance (using a group of other cells instead a cell) but sometimes being able to use templates from parent cell class is more convenient. 
I use it when I have several cells for page content of several page types but the layout is similar (same number of sections but different content). 
Just read the [view inheritance document](https://github.com/trailblazer/cells/tree/v4.0.5#view-inheritance) yourself.  

### Caching

Best part comes last. 
Caching is easy, cache invalidation is not. 
But let's talk about how to enable caching first. 

Enabling caching is quite easy according to [caching doc](https://github.com/trailblazer/cells/tree/v4.0.5#caching):
```ruby
class CopyableUrlBox::Cell < ApplicationConceptCell
  cache :show
end
```
Yup. The end. 
Except when you pass different inputs into it, the cell will give you the same (cached) output. Which is why cache invalidation is the hard part.

First get some basic concept by looking at the [caching doc](https://github.com/trailblazer/cells/tree/v4.0.5#caching)

```ruby
cache :show, expires_in: 10.minutes
```
This is just making the cached content expires after some time, nope not what we want.

```ruby
cache :show { |model, options| "comment/#{model.id}/#{model.updated_at}" }
cache :show, :if => lambda { |*| has_changed? }
cache :show, :tags: lambda { |model, options| "comment-#{model.id}" }
```
Looks messy? Let me give you my version

```ruby
cache(
  :show,
  :cache_key,
  expires_in: :cache_valid_time_period_length,
  if: :can_be_cached?,
)

def can_be_cached?
  # Use following code when child cell(s) is/are used
  # [
  #   child_cell_1,
  # ].all?(&:can_be_cached?)

  true
end

def cache_valid_time_period_length
  # Use following code when child cell(s) is/are used
  # [
  #   child_cell_1,
  # ].map(&:cache_valid_time_period_length).min

  # Long time
  100.years
end

def cache_key
  {
    current_locale: ::I18n.config.locale,

    url: Digest::MD5.hexdigest(url),
  }
end
```
If language changed (`::I18n.config.locale`) or input value different (`url`), a different cache key would be used and the render result (cached or not) would be different

But hold on, what if I updated the logic/template and now I want it to return a result rendered with latest logic?

This is what I **did**:
```ruby
class ApplicationConceptCell < Cell::Concept
  DEFAULT_CLASS_LEVEL_CACHE_KEY = {
    # ONLY change this when there are too many cells caching needs updated
    # Also only affects cells that has `#cache_key` binding to `super`

    # Yes I use cells since 2015
    design: :v2015_12_22_1001,
  }.freeze
  private_constant :DEFAULT_CLASS_LEVEL_CACHE_KEY

  def self.cache_key
    DEFAULT_CLASS_LEVEL_CACHE_KEY
  end

  def cache_key
    self.class.cache_key
  end
end

class CopyableUrlBox::Cell < ApplicationConceptCell
  cache(
    :show,
    :cache_key,
    expires_in: :cache_valid_time_period_length,
    if: :can_be_cached?,
  )

  def can_be_cached?
    # Use following code when child cell(s) is/are used
    # [
    #   child_cell_1,
    # ].all?(&:can_be_cached?)

    true
  end

  def cache_valid_time_period_length
    # Use following code when child cell(s) is/are used
    # [
    #   child_cell_1,
    # ].map(&:cache_valid_time_period_length).min

    # Long time
    100.years
  end

  def self.cache_key
    super.merge(
      logic: :v2020_12_18_1727,
    )
  end

  def cache_key
    super.merge(
      current_locale: ::I18n.config.locale,

      url: Digest::MD5.hexdigest(url),
    )
  end
end
```

Notice that the above code all use `cache_key`.  
But [Rails 5.2 introduced *cache versioning*](https://bigbinary.com/blog/rails-adds-support-for-recyclable-cache-keys) and we should use that too.

#### Caching - With Cache Versioning
The following code enable *cache versioning*
```ruby
class ApplicationConceptCell < Cell::Concept
  DEFAULT_CLASS_LEVEL_CACHE_KEY = {
    # Empty
  }.freeze
  private_constant :DEFAULT_CLASS_LEVEL_CACHE_KEY

  DEFAULT_CLASS_LEVEL_CACHE_VERSION = {
    # ONLY change this when there are too many cells caching needs updated
    # Also only affects cells that has `#cache_key` binding to `super`

    # Yes I use cells since 2015
    design: :v2015_12_22_1001,
  }.freeze
  private_constant :DEFAULT_CLASS_LEVEL_CACHE_VERSION

  def self.cache_key
    DEFAULT_CLASS_LEVEL_CACHE_KEY
  end

  def cache_key
    self.class.cache_key
  end

  def self.cache_version
    DEFAULT_CLASS_LEVEL_CACHE_VERSION
  end

  def cache_version
    self.class.cache_key
  end
end

class ProductCardWide::Cell < ApplicationConceptCell
  cache(
    :show,
    :cache_key,
    expires_in: :cache_valid_time_period_length,
    if: :can_be_cached?,
    version: :cache_version,
  )

  def can_be_cached?
    # Use following code when child cell(s) is/are used
    # [
    #   child_cell_1,
    # ].all?(&:can_be_cached?)

    true
  end

  def cache_valid_time_period_length
    # Use following code when child cell(s) is/are used
    # [
    #   child_cell_1,
    # ].map(&:cache_valid_time_period_length).min

    # Long time
    100.years
  end

  # def self.cache_key
  #   super
  # end

  def cache_key
    super.merge(
      current_locale: ::I18n.config.locale,

      # Assume product is an active record object 
      product: product.id,
    )
  end

  def self.cache_version
    super.merge(
      logic:    :v2017_12_22_1124,
    )
  end

  def cache_version
    super.merge(
      # Assume product is an active record object 
      product: product.updated_at,
    )
  end
end
```
Point is when calling `.cache` in class, add option `version`.  


Logic/Template/Translation/Images updated?  
You can of course manually update the class level cache key/version.  
But let's see how I make them auto update themselves.  

#### Caching - Class Level Cache Key/Version Auto Update

**1. Template Updates**
This assumes every template update should cause cache invalidation (so even a refactor would invalidate the cache).  
```ruby
class Some::Cell < ApplicationConceptCell
  def self.cache_version
    super.merge(
      template: TEMPLATE_FILES_CONTENT_CACHE_KEY,
    )
  end

  # This generates the cache key/version from the content of all direct templates for this cell (templates from parent cells or custom paths NOT included)
  # Any change (even a refactor) would cause the value to change
  TEMPLATE_FILES_CONTENT_CACHE_KEY = begin
    view_folder_path = File.expand_path("views", __dir__)
    file_paths = Dir.glob(File.join(view_folder_path, "**", "*"))

    file_digests = file_paths.map do |file_path|
      ::Digest::MD5.hexdigest(File.read(file_path))
    end

    ::Digest::MD5.hexdigest(file_digests.join(""))
  end
  private_constant :TEMPLATE_FILES_CONTENT_CACHE_KEY
end
```

**2. Translation Updates**
This requires you to have separate folders of translation files for different components.  
```ruby
class Some::Cell < ApplicationConceptCell
  def self.cache_version
    super.merge(
      translations: TRANSLATION_FILES_CONTENT_CACHE_KEY,
    )
  end

  # This generates the cache key/version from the content of all specified folder's files
  # Any change (even a refactor) would cause the value to change
  TRANSLATION_FILES_CONTENT_CACHE_KEY = begin
    folder_path = ::Rails.root.join(
      "config/locales/your/cell/translation/folder/path",
    )
    file_paths = Dir.glob(File.join(folder_path, "**", "*"))

    file_digests = file_paths.map do |file_path|
      next nil unless File.file?(file_path)

      ::Digest::MD5.hexdigest(File.read(file_path))
    end

    ::Digest::MD5.hexdigest(file_digests.join(""))
  end
  private_constant :TRANSLATION_FILES_CONTENT_CACHE_KEY
end
```

**3. Image Updates**
```ruby
class Some::Cell < ApplicationConceptCell
  def self.cache_version
    super.merge(
      images: IMAGE_FILES_CONTENT_CACHE_KEY,
    )
  end

  # This generates the cache key/version from the content of all specified folder's files
  # Any change (even a refactor) would cause the value to change
  IMAGE_FILES_CONTENT_CACHE_KEY = begin
    folder_paths = [
      # "app/assets/images/path/to/images",
    ]
    asset_logical_paths = [
      # "app/assets/images/path/to/images.jpg",
    ].map do |asset_logical_path|
      ::Rails.root.join(asset_logical_path)
    end
    file_paths = folder_paths.inject([]) do |paths, folder_path|
      paths + ::Dir.glob(::File.join(::Rails.root.join(folder_path), "**", "*"))
    end + asset_logical_paths
  
    file_digests = file_paths.map do |file_path|
      next nil unless File.file?(file_path)
  
      ::Digest::MD5.hexdigest(File.read(file_path))
    end
  
    ::Digest::MD5.hexdigest(file_digests.join(""))
  end
  private_constant :ASSET_FILES_CONTENT_CACHE_KEY
end
```

**4. Cell Logic Updates**
Nope. You have to update cache key/version manually for this kind of updates.


## The End
I still got some more to share about caching but let's save it for another post. (This post is for "beginners")  
The code above is extracted from an existing project so let me know if there is any mistake.  
Took me almost 3 months to finish this article (o.0)