Skip to content

Latest commit

 

History

History
371 lines (280 loc) · 8.4 KB

README.md

File metadata and controls

371 lines (280 loc) · 8.4 KB

Clapton

version downloads license

Clapton is a Ruby on Rails gem for building web apps with pure Ruby only (no JavaScript and no HTML templates).

Stack

  • Ruby on Rails
  • Action Cable (WebSocket)
  • Ruby2JS (for compiling Ruby to JavaScript)
  • Morphdom
  • importmap

Installation

Add this line to your application's Gemfile:

gem 'clapton'

And then execute:

$ bundle install

Usage

To use a Clapton component in your view:

# app/components/task_list_component.rb
class TaskListComponent < Clapton::Component
  def render
    div = c(:div)
    @state.tasks.each do |task|
      div.add(TaskItemComponent.new(id: task[:id], title: task[:title], due: task[:due], done: task[:done]))
    end
    btn = c(:button)
    btn.add(c(:text, "Add Task"))
    btn.add_action(:click, :TaskListState, :add_task)
    div.add(btn)
  end
end
# app/components/task_item_component.rb
class TaskItemComponent < Clapton::Component
  def render
    div = c(:div)
    btn = c(:button)
    btn.add(c(:text, @state.done ? "✅" : "🟩"))
    btn.add_action(:click, :TaskListState, :toggle_done)

    tf = c(:input, @state, :title)
    tf.add_action(:input, :TaskListState, :update_title)

    dt = c(:datetime, @state, :due)
    dt.add_action(:input, :TaskListState, :update_due)

    div.add(btn).add(tf).add(dt)
  end
end
# app/states/task_list_state.rb
class TaskListState < Clapton::State
  attribute :tasks

  def add_task(params)
    task = Task.create(title: "New Task", due: Date.today, done: false)
    self.tasks << { id: task.id, title: task.title, due: task.due, done: task.done }
  end

  def toggle_done(params)
    task = Task.find(params[:id])
    task.update(done: !params[:done])
    self.tasks.find { |t| t[:id] == params[:id] }[:done] = task.done
  end

  def update_title(params)
    task = Task.find(params[:id])
    task.update(title: params[:title])
    self.tasks.find { |t| t[:id] == params[:id] }[:title] = task.title
  end

  def update_due(params)
    task = Task.find(params[:id])
    task.update(due: params[:due])
    self.tasks.find { |t| t[:id] == params[:id] }[:due] = task.due
  end
end
# app/states/task_item_state.rb
class TaskItemState < Clapton::State
  attribute :id
  attribute :title
  attribute :due
  attribute :done
end
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
  def index
    @tasks = Task.all
    @components = [
      [:TaskListComponent, { tasks: @tasks.map { |task| { id: task.id, title: task.title, due: task.due, done: task.done } } }]
    ]
  end
end
# app/views/layouts/application.html.erb
<%= clapton_javascript_tag %>
# app/views/tasks/index.html.erb
<%= clapton_tag %>

Make sure to include the necessary route in your config/routes.rb:

mount Clapton::Engine => "/clapton"

TODO APP DEMO

Component rendering

<%= clapton_component_tag(
  :TaskListComponent,
  {
    tasks: @tasks.map { |task| { id: task.id, title: task.title, due: task.due, done: task.done } }
  }
) %>

Generate Component and State

rails generate clapton TaskList

After running the generator, you will see the following files:

  • app/components/task_list_component.rb
  • app/states/task_list_state.rb

Special Event

render

The render event is a special event that is triggered when the component is rendered.

# app/components/task_list_component.rb
class TaskListComponent < Clapton::Component
  def render
    # ...
    div = c(:div)
    div.add_action(:render, :TaskListState, :add_empty_task, debounce: 500)
  end
end

Effect

The effect method is a method that is triggered when the state is changed.

# app/components/task_list_component.rb
class TaskListComponent < Clapton::Component
  effect [:tasks] do |state|
    puts state[:tasks]
  end
end

If dependencies are not specified, the effect will be triggered on the first render.

# app/components/video_player_component.rb
class VideoPlayerComponent < Clapton::Component
  effect [] do
    puts "First render"
  end
end

Streaming

Clapton supports streaming.

# app/states/chat_state.rb
class ChatState < Clapton::State
  attribute :messages

  def send(params)
    self.messages << { role: "user", content: params[:content] }
    yield continue: true # Continue the streaming

    client = OpenAI::Client.new(
      access_token: ENV.fetch("OPENAI_ACCESS_TOKEN"),
      log_errors: true
    )
    self.messages << { role: "assistant", content: "" }
    client.chat(
      parameters: {
        model: "gpt-4o-mini",
        messages: messages,
        stream: proc do |chunk, _bytesize|
          if chunk.dig("choices", 0, "finish_reason") == "stop"
            yield continue: false # Stop the streaming
          end

          self.messages.last[:content] << chunk.dig("choices", 0, "delta", "content")
          yield continue: true
        end
      }
    )
  end
end

Optional

Action Cable

Clapton uses Action Cable to broadcast state changes to the client. If you want to identify the user, you can set the current_user in the connection.

# app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_verified_user
    end

    private

    def find_verified_user
      if verified_user = User.find_by(id: cookies.signed[:user_id])
        verified_user
      else
        reject_unauthorized_connection
      end
    end
  end
end

Using with importmap-rails

Use clapton_javascript_tag instead of javascript_importmap_tags.

- <%= javascript_importmap_tags %>
+ <%= clapton_javascript_tag %>

Events

clapton:render

The clapton:render event is a custom event that is triggered when the component is rendered.

document.addEventListener("clapton:render", () => {
  console.log("clapton:render");
});

Testing

RSpec

# spec/spec_helper.rb
require "clapton/test_helper/rspec"

RSpec.configure do |config|
  config.include Clapton::TestHelper::RSpec, type: :component
end
# spec/components/task_list_component_spec.rb

describe "TaskListComponent", type: :component do
  it "renders" do
    render_component("TaskListComponent", tasks: [{ id: 1, title: "Task 1", done: false, due: Time.current }])
    # You can use Capybara matchers here
    expect(page).to have_selector("input[type='text']")
  end
end

Minitest

# test/test_helper.rb
require "clapton/test_helper/minitest"

class ActiveSupport::TestCase
  include Clapton::TestHelper::Minitest
end
# test/components/task_list_component_test.rb
class TaskListComponentTest < ActiveSupport::TestCase
  test "renders" do
    render_component("TaskListComponent", tasks: [{ id: 1, title: "Task 1", done: false, due: Time.current }])
    # You can use Capybara matchers here
    assert_select "input[type='text']"
  end
end

Deployment

Run bundle exec rake clapton:compile to compile the components.

app/components is codes that are compiled to JavaScript. So, you need to ignore the directory from autoloading.

# config/application.rb

Rails.autoloaders.main.ignore(Rails.root.join("app/components"))

Development

After checking out the repo, run bin/setup to install dependencies. Then, run bin/dev to start the development server.

Testing

Run bundle exec rake test to run the test suite.

Run cd test/dummy && bundle exec rake test to run the test suite for the dummy app.

Run cd test/dummy && bundle exec rspec to run the test suite for the dummy app with RSpec.

Run cd lib/clapton/javascripts && npm run test to run the test suite for the JavaScript part.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/kawakamimoeki/clapton. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

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