0.0
Repository is archived
No release in over a year
A simple component library which seamlessly hooks into your Rails project and allows you to create simple backend components. They work like mini controllers which are bound with their view.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Runtime

 Project Readme

license Gem Version Maintainability CI badge Coverage Badge

AmberComponent

AmberComponent is a simple component library which seamlessly hooks into your Rails project and allows you to create simple backend components which consist of a Ruby controller, view, stylesheet and even a JavaScript controller (using Stimulus).

Created by Garbus Beach and Mateusz Drewniak.

Getting started

You can read a lot more about AmberComponent in its official docs.

Installation

Install the gem and add to the application's Gemfile by executing:

$ bundle add amber_component

If bundler is not being used to manage dependencies, install the gem by executing:

$ gem install amber_component

If you're using a Rails application there's an installation generator that you should run:

$ bin/rails generate amber_component:install

Amber component supports Stimulus to make your components reactive using JavaScript.

If you want to use stimulus you should install this gem with the --stimulus flag

$ bin/rails generate amber_component:install --stimulus

Usage

Components

Components are located under app/components. And their tests under test/components.

Every component consists of:

  • a Ruby file which defines its properties, encapsulates logic and may implement helper methods (like a controller)
  • a view/template file (html.erb, haml, slim etc.)
  • a style file (css, scss, sass etc.)
  • [optional] a JavaScript file with a Stimulus controller (if you installed the gem with --stimulus)

amber_component automatically detects what kind of view and stylesheet formats your app is configured to use.

So if you've got haml-rails, components will be generated with haml. When your app uses slim-rails, components will be generated with slim. When your Gemfile contains sassc-rails, components will use scss etc.

All of these formats can be overridden in an initializer or by adding arguments to the component generator.

app/components/
├─ [name]_component.rb
└─ [name]_component/
   ├─ style.css     # may be .sass or .scss
   ├─ view.html.erb
   └─ controller.js # if stimulus is configured
test/components/
└─ [name]_component_test.rb

An individual component which implements a button may look like this.

# app/components/button_component.rb

class ButtonComponent < AmberComponent::Base
  prop :label, required: true
end
<!-- app/components/button_component/view.html.erb -->

<div class="button_component"
     data-controller="button-component"
     data-action="click->button-component#greet">
    <%= label %>
</div>
// app/components/button_component/style.scss

.button_component {
    background-color: indigo;
    border-radius: 1rem;
    transition-duration: 500ms;

    &:hover {
        background-color: blue;
    }
}

If you used the --stimulus option when installing the gem, a JS controller will be generated as well.

// app/components/button_component/controller.js

import { Controller } from "@hotwired/stimulus"

// Read more about Stimulus here https://stimulus.hotwired.dev/
export default class extends Controller {
  connect() {
    console.log("Stimulus controller 'button-component' is connected!")
  }

  greet() {
    alert("Hi there!")
  }
}

You can render this component in other components or in a Rails view.

<!-- app/controller/foo/index.html.erb -->

<h1>We're inside FooController</h1>

<!-- using a helper method -->
<%= button_component label: 'Click me!' %>
<!-- calling a method on the component class -->
<%= ButtonComponent.call label: 'Click me!' %>

Or even directly in Ruby

# Calling a method on the component class. Outputs an HTML string.
ButtonComponent.call label: 'Click me!'
#=> '<div class="button_component">Click me!</div>'

Components with namespaces

Components may be defined inside multiple modules/namespaces.

# app/components/sign_up/button_component.rb

class SignUp::ButtonComponent < AmberComponent::Base
  prop :label, required: true
end
<!-- app/components/sign_up/button_component/view.html.erb -->

<div class="sign_up_button_component">
    <%= label %>
</div>
// app/components/sign_up/button_component/style.scss

.sign_up_button_component {
    background-color: indigo;
    border-radius: 1rem;
    transition-duration: 500ms;

    &:hover {
        background-color: blue;
    }
}

You can render such a component by calling the ::call method on its class, or by using the helper method defined on its parent module.

SignUp::ButtonComponent.call label: 'Sign up!'
SignUp.button_component label: 'Sign up!'

Generating Components

You can generate new components by running

$ bin/rails generate component [name]

Name of the component may be PascalCased like FooBar or snake_cased foo_bar

This will generate a new component in app/components/[name]_component.rb along with a view, stylesheet, test file and a stimulus controller (if configured).

app/components/
├─ [name]_component.rb
└─ [name]_component/
   ├─ style.css     # may be `.scss` or `.sass`
   ├─ view.html.erb # may be `.haml` or `.slim`
   └─ controller.js # if stimulus is configured
test/components/
└─ [name]_component_test.rb

View and stylesheet formats can be overridden by providing options.

-v, [--view=VIEW]          # Indicate what type of view should be generated eg. [:erb, :haml, :slim]
--styles, -c, [--css=CSS]  # Indicate what type of styles should be generated eg. [:css, :scss, :sass]

Component properties

There is a neat prop DSL.

# app/components/comment_component.rb

class CommentComponent < ApplicationComponent
    # will raise an error when not present
    prop :body, required: true
    # will raise an error when an object of a different
    # class is received (uses `is_a?`)
    prop :author, type: User, allow_nil: true
    # the default value
    prop :date, default: -> { DateTime.now }
end

Props can be passed as keyword arguments to the ::call method of the component class or the helper method.

CommentComponent.call body: 'Foo bar', author: User.first
# only in views and other components
comment_component body: 'Foo bar', author: User.first

Helper methods

Defining helper methods which are available in the template is extremely easy.

Just define a method on the component class.

# app/components/comment_component.rb

class CommentComponent < ApplicationComponent
    # you can also include helper modules
    include SomeHelper

    prop :body, required: true
    prop :author, type: Author, allow_nil: true
    prop :date, default: -> { DateTime.now }

    private

    def humanized_date
        date.strftime '%Y-%m-%d %H:%M'
    end

    def author_name
        author&.name || 'Unknown'
    end

    def author_avatar
        author&.avatar_url || User.placeholder_avatar_url
    end
end
<!-- app/components/comment_component/view.html.erb -->

<div class="comment_component">
    <div class="comment_header">
        <img src="<%= author_avatar %>" alt="<%= author_name %> avatar">

        <div><%= author_name %></div>
        <div class="comment_date"><%= humanized_date %></div>
    </div>

    <div class="comment_content">
        <%= body %>
    </div>
</div>

Overriding prop getters and setters

Getters and setters for properties are defined in a module which means that you can override them and call them with super.

# app/components/priority_icon_component.rb

class PriorityIconComponent < ApplicationComponent
    PriorityStruct = Struct.new :icon, :color

    PRIORITY_MAP = {
        low: PriorityStruct.new('fa-solid fa-chevrons-down', 'green'),
        medium: PriorityStruct.new('fa-solid fa-chevron-up', 'yellow'),
        high: PriorityStruct.new('fa-solid fa-chevrons-up', 'red')
    }

    prop :severity, default: -> { :low }

    def severity=(val)
      # super will call the original
      # implementation of the setter
      super(PRIORITY_MAP[val])
    end
end
<!-- app/components/priority_icon_component/view.html.erb -->

<i style="color: <%= severity&.color %>;" class="<%= severity&.icon %>"></i>

Nested components

It's possible to nest components or provide custom HTML to a component.

This works similarly to React's props.children.

To render the passed nested content call children(&block) somewhere inside the ERB template/view. If you're using another template language like Haml, you may need to use children{yield} instead. This difference is due to how these templates are compiled.

# app/components/modal_component.rb

class ModalComponent < ApplicationComponent
    prop :id, required: true
    prop :title, required: true
end
<!-- app/components/modal/view.html.erb -->

<div id="<%= id %>" class="modal_component">
    <div class="model_header">
        <%= title %>
    </div>

    <div class="modal_body">
        <!-- nested content will be rendered here -->
        <%= children(&block) %>
    </div>

    <div class="modal_footer">
        <div class="modal_close_button"></div>
    </div>
<div>

You can pass a body to this modal by passing a block.

<!-- app/controller/tasks/show.html.erb -->

<%= ModalComponent.call id: 'update-task-modal' title: 'Update the task' do %>
    <!-- You can provide HTML and render other components -->
    <h2>This is your task!</h2>
    <%= form_with model: @task do |f| %>
        <%= f.text_field :name %>
        <%= f.text_area :description %>
        <%= f.submit %>
    <% end %>
    <%= OtherComponent.call some: 'prop' %>
<% end %>

Note that this will raise an error when no block/nested content is provided.

In order to render nested content only when it is present (will work without nested content) you can use children(&block) if block_given? in ERB templates (or children{yield} if block_given? for Haml and others)

In general block_given? will return true when a block/nested content is present, otherwise false. You can use it to render content conditionally based on whether nested content is present.

Rails helpers inside component templates

Component views/template files can make use of all ActionView helpers and Rails route helpers.

This makes component views very flexible and convenient.

<!-- app/components/login_form_component/view.html.erb -->

<%= form_with url: sign_up_path, class: "login_form_component" do |f| %>
  <%= f.label :first_name %>
  <%= f.text_field :first_name %>

  <%= f.label :last_name %>
  <%= f.text_field :last_name %>

  <%= f.label :email, "Email Address" %>
  <%= f.text_field :email %>

  <%= f.label :password %>
  <%= f.password_field :password %>

  <%= f.label :password_confirmation, "Confirm Password" %>
  <%= f.password_field :password_confirmation %>

  <%= f.submit "Create account" %>
<% end %>

Configuration

This gem can be configured in an initializer. If you used the installer generator it should already be present.

# config/initializers/amber_component.rb

::AmberComponent.configure do |c|
    c.stimulus = nil # [nil, :importmap, :webpacker, :jsbundling, :webpack, :esbuild, :rollup]
    c.stylesheet_format = :css # [:css, :scss, :sass]
    c.view_format = :erb # [:erb, :haml, :slim]
end

Testing Components

Rails

After setting up this gem with the rails generator rails generate amber_component:install a new abstract test class will be available called ApplicationComponentTestCase.

It provides a handful of helper methods to make it easier to inspect the rendered HTML.

A simple test file may look like this:

# test/components/foo_component_test.rb

require 'test_helper'

class FooComponentTest < ApplicationComponentTestCase
    test 'render correct HTML' do
        # Specify what the assertions are supposed to
        # check against.
        #
        # There can be multiple renders in one test
        # but they override the previous one.
        # So there is only one rendered component
        # at any given time.
        render do
            FooComponent.call some: 'prop'
        end

        # Assertions on the rendered HTML

        # Use a CSS selector
        assert_selector ".foo_component span.my_class", text: 'Some Text'
        # Check text
        assert_text 'Amber Component is awesome!'
    end
end

A full list of available assertions can be found here.

Non Rails

There is a test case class for minitest. You can access it by requiring 'amber_component/minitest_test_case'.

It has the same assertion methods as the Rails test case class. It requires capybara to be installed and present in the Gemfile.

A full list of available assertions can be found here.

require 'amber_component/minitest_test_case'

class FooComponentTest < AmberComponent::MinitestTestCase
    def test_render_correct_html
        # Specify what the assertions are supposed to
        # check against.
        #
        # There can be multiple renders in one test
        # but they override the previous one.
        # So there is only one rendered component
        # at any given time.
        render do
            FooComponent.call some: 'prop'
        end

        # Assertions on the rendered HTML

        # Use a CSS selector
        assert_selector ".foo_component span.my_class", text: 'Some Text'
        # Check text
        assert_text 'Amber Component is awesome!'
    end
end

There is also a helper module which provides all of these assertions under 'amber_component/test_helper'.

require 'amber_component/test_helper'

class MyAbstractTestCase < ::Minitest::Test
    include ::AmberComponent::TestHelper
end

Note that this module has only been tested with minitest and rails test suites, so it may require overriding or implementing a few methods to work with other test suites.

Contribute

Do you want to contribute to AmberComponent? Open the issues page and check for the help wanted label! But before you start coding, please read our Contributing Guide.

Bug reports and pull requests are welcome on GitHub at https://github.com/amber-ruby/amber_component.

License

The gem is available as open source under the terms of the MIT License.