In this step-by-step guide, I will show you how to create a Twitter clone in pure Ruby with Matestack, following the great screencasts from Chris McCord Phoenix LiveView Twitter Clone and Nate Hopkins Stimulus Reflex Twitter Clone. We will use the Gem matestack-ui-core
, which enables us to implement our UI in some Ruby classes rather than writing ERB, HAML or Slim views. Furthermore we don't need to touch JavaScript in order to create reactive UI features, such as updating the DOM without a full browser page reload or syncing multiple web clients through Action Cable!
I've added a screencast (two parts) showing you what you will be doing in this tutorial:
This guide utilizes the full power of Matestack and uses matestack-ui-core
as a complete substitute for Rails views. If you only want to create UI components in pure Ruby on existing Rails views, please check out this guide
Create a new Rails app and install some dependencies:
rails new twitter_clone --webpackercd twitter_clonebundle add matestack-ui-coreyarn add matestack-ui-core
Use Rails scaffolder in order to setup some files:
rails g scaffold Post body:text likes_count:integer username:string
Modify generated migration in order to add defaults:
db/migrate/12345_create_posts.rb
class CreatePosts < ActiveRecord::Migration[6.0]def changecreate_table :posts do |t|t.text :bodyt.integer :likes_count, default: 0 # add defaultt.string :usernamet.timestampsendendend
Migrate the database:
rails db:migrate
Add validations and default ordering to the Post
model:
app/models/post.rb
class Post < ApplicationRecorddefault_scope { order(created_at: :desc) }validates :body, presence: true, allow_blank: falsevalidates :username, presence: trueend
Modify the JavaScript pack in order to require matestack-ui-core
and deactivate turbolinks
:
app/javascript/packs/application.js
// This file is automatically compiled by Webpack, along with any other files// present in this directory. You're encouraged to place your actual application logic in// a relevant structure within app/javascript and only use these pack files to reference// that code so it'll be compiled.require("@rails/ujs").start()// require("turbolinks").start() //removerequire("@rails/activestorage").start()require("channels")import MatestackUiCore from 'matestack-ui-core' //add
On app/views/layouts/application.html.erb
do:
Add Bootstrap CSS via CDN (or any other CSS framework)
Remove turbolinks attributes (just for cleanup)
Add the matestack-ui
ID to a DOM element within (not on!) the body
tag
app/views/layouts/application.html.erb
<!DOCTYPE html><html><head><title>TwitterClone</title><%= csrf_meta_tags %><%= csp_meta_tag %><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"><link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous"><%= stylesheet_link_tag 'application', media: 'all' %><%= javascript_pack_tag 'application' %></head><body><div id="matestack-ui"><%= yield %></div></body></html>
Delete all generated app/views/posts
views - we don't need them!
Add Matestack's helper to your application controller:
app/controllers/application_controller.rb
class ApplicationController < ActionController::Baseinclude Matestack::Ui::Core::ApplicationHelperend
Remove not required code from the generated controller:
app/controllers/posts_controller.rb
class PostsController < ApplicationController# GET /posts# GET /posts.jsondef index@posts = Post.allend# POST /posts# POST /posts.jsondef create@post = Post.new(post_params)respond_to do |format|if @post.saveformat.html { redirect_to @post, notice: 'Post was successfully created.' }format.json { render :show, status: :created, location: @post }elseformat.html { render :new }format.json { render json: @post.errors, status: :unprocessable_entity }endendendprivate# Only allow a list of trusted parameters through.def post_paramsparams.require(:post).permit(:username, :body)endend
Add a matestack
folder and create a Matestack app and a Matestack Post
index page file:
mkdir -p app/matestack/twitter_clonetouch app/matestack/twitter_clone/app.rbmkdir -p app/matestack/twitter_clone/pages/poststouch app/matestack/twitter_clone/pages/posts/index.rb
Add some basic code to the files:
app/matestack/twitter_clone/app.rb
class TwitterClone::App < Matestack::Ui::Appdef responsediv class: "container" doheading size: 1, text: "Twitter Clone", class: "mb-5"yield_pageendendend
app/matestack/twitter_clone/pages/posts/index.rb
class TwitterClone::Pages::Posts::Index < Matestack::Ui::Pagedef prepare@posts = Post.allenddef response@posts.each do |post|div class: "mb-3 p-3 rounded shadow-sm" doheading size: 5 doplain post.usernamesmall text: post.created_at.strftime("%d.%m.%Y %H:%M")endparagraph text: post.bodyendendendend
Reference Matestack's app and page on the controller and index
action:
app/controllers/posts_controller.rb
class PostsController < ApplicationControllermatestack_app TwitterClone::App # add this# GET /posts# GET /posts.jsondef index# @posts = Post.allrender TwitterClone::Pages::Posts::Index # add thisend# ...end
Test the current state
Fire up the Rails server and see what we got until now (heads up: we need a little bit more code)
rails s
Navigate to localhost:3000/posts
You should see the heading "Twitte Clone" and that's it. We don't have any posts in our database, so we need a form
to create one!
Add a reactive form
to the index page
Use the form_config_helper
method returning a config hash in order to inject a hash into the form without polluting the code
Refactor the code by using methods serving as 'partials'
app/matestack/twitter_clone/pages/posts/index.rb
class TwitterClone::Pages::Posts::Index < Matestack::Ui::Pagedef prepare@posts = Post.allenddef responsepost_form_partialpost_list_partialendprivatedef post_form_partialdiv class: "mb-3 p-3 rounded shadow-sm" doheading size: 4, text: "New Post", class: "mb-3"form form_config_helper dodiv class: "mb-3" doform_input key: :username, type: :text, placeholder: "Username", class: "form-control"enddiv class: "mb-3" doform_textarea key: :body, placeholder: "What's up?", class: "form-control"enddiv class: "mb-3" doform_submit dobutton type: :submit, class: "btn btn-primary", text: "Post!"endendendendenddef form_config_helper{for: Post.new, path: posts_path, method: :post,# optional: in order to map Bootstrap's CSS classes, you can adjust the form error rendering like so:errors: { wrapper: { tag: :div, class: 'invalid-feedback' }, input: { class: 'is-invalid' } }}enddef post_list_partial@posts.each do |post|div class: "mb-3 p-3 rounded shadow-sm" doheading size: 5 doplain post.usernamesmall text: post.created_at.strftime("%d.%m.%Y %H:%M")endparagraph text: post.bodyendendendend
Slightly change the response within the create
action in order to support the new form:
app/controllers/posts_controller.rb
class PostsController < ApplicationController# ...# POST /posts# POST /posts.jsondef create@post = Post.new(post_params)# respond_to do |format|# if @post.save# format.html { redirect_to @post, notice: 'Post was successfully created.' }# format.json { render :show, status: :created, location: @post }# else# format.html { render :new }# format.json { render json: @post.errors, status: :unprocessable_entity }# end# endif @post.saverender json: {message: 'Post was successfully created.'}, status: :createdelserender json: {errors: @post.errors,message: 'Post could not be created.'}, status: :unprocessable_entityendend# ...end
Test the current state
Navigate to localhost:3000/posts
You should see a basic index page with a form on top. When submitting the form without any values, ActiveRecord errors should appear below the inputs without browser page reload. When submitting valid data, the form should reset automatically without browser page reload, but you would have to reload the browser in order to see the new post! That's why we need to add the async
component.
Add success: { emit: "submitted" }
to the form config
Wrap the post_list_partial
with an async
, configured to rerender when the event submitted
is received
app/matestack/twitter_clone/pages/posts/index.rb
# ...def form_config_helper{for: Post.new, path: posts_path, method: :post,errors: {wrapper: { tag: :div, class: 'invalid-feedback' },input: { class: 'is-invalid' }},success: { emit: "submitted" }}enddef post_list_partialasync rerender_on: "submitted", id: "post-list" do@posts.each do |post|div class: "mb-3 p-3 rounded shadow-sm" doheading size: 5 doplain post.usernamesmall text: post.created_at.strftime("%d.%m.%Y %H:%M")endparagraph text: post.bodyendendendend# ...
Test the current state
Navigate to localhost:3000/posts
Cool! Now you should see the list automatically updating itself after form submission without a browser page reload! And we didn't write any JavaScript for that. Just two lines of simple Ruby code! How cool is that?
Now we need to add some action
components in order to "like" the posts.
Add the like
route:
config/routes.rb
Rails.application.routes.draw doresources :posts domember doput 'like', to: 'posts#like'endendend
Add the like
action on the controller:
app/controller/posts_controller.rb
# ...# PUT /posts/1/likedef like@post = Post.find params[:id]@post.increment(:likes_count)if @post.saverender json: {message: 'Post was successfully liked.'}, status: :createdelserender json: {errors: @post.errors,message: 'Post could not be liked.'}, status: :unprocessable_entityendend# ...
Refactor the post_list_partial and use another method, defining the post itself
Add the like
action component and emit a post-specific event
Wrap the post in an async, configured to rerender itself on the event emitted by the action component
app/matestack/twitter_clone/pages/index.rb
# ...def post_list_partialasync rerender_on: "submitted", id: "post-list" do@posts.each do |post|post_partial(post)endendenddef post_partial(post)async rerender_on: "liked_post_#{post.id}", id: "post-#{post.id}" dodiv class: "mb-3 p-3 rounded shadow-sm" doheading size: 5 doplain post.usernamesmall text: post.created_at.strftime("%d.%m.%Y %H:%M")endparagraph text: post.body, class: "mb-5"action path: like_post_path(post), method: :put, success: { emit: "liked_post_#{post.id}" } dobutton class: "btn btn-light" doplain "Like (#{post.likes_count})"endendendendend# ...
Test the current state
Navigate to localhost:3000/posts
When you click the "Like" button on a post, you will see the counter increasing without a full page reload! Again: Reactivity without any JavaScript!
Great! We added a reactive form and reactive actions. We can now add some reactive feedback on top using the toggle component!
Add failure event submission to the form config like: failure: { emit: "form_failed" },
Add a toggle
component in order to render the success message for 5 seconds
Add a toggle
component in order to render the failure message for 5 seconds
app/matestack/twitter_clone/pages/index.rb
class TwitterClone::Pages::Posts::Index < Matestack::Ui::Pagedef prepare@posts = Post.allenddef responsepost_form_partialpost_list_partialendprivatedef post_form_partialdiv class: "mb-3 p-3 rounded shadow-sm" doheading size: 4, text: "New Post", class: "mb-3"form form_config_helper do# ...endendtoggle show_on: "submitted", hide_after: 5000 dodiv class: "container fixed-bottom w-100 bg-success text-white p-3 rounded-top" doheading size: 4, text: "Success: {{ event.data.message }}"endendtoggle show_on: "form_failed", hide_after: 5000 dodiv class: "container fixed-bottom w-100 bg-danger text-white p-3 rounded-top" doheading size: 4, text: "Error: {{ event.data.message }}"endendendprivatedef form_config_helper{for: Post.new, path: posts_path, method: :post,success: { emit: "submitted" },failure: { emit: "form_failed" },errors: { wrapper: { tag: :div, class: 'invalid-feedback' }, input: { class: 'is-invalid' } }}end# ...end
Test the current state
Navigate to localhost:3000/posts
Great! Now we get instant feedback after performing successful or non successful form submissions! And still no line of JavaScript involved! The same approach would work for our actions, but we do not want to have that feedback after performing the actions in this example!
All of the above described reactivity only applies for one client. A second user wouldn't see the new post, unless he reloads his browser page. But of course, we want to sync all connected clients! It's time to integrate ActionCable!
Generate an ActionCabel channel
rails generate channel MatestackUiCoreChannel
Adjust the generated files like:
app/javascript/channels/matestack_ui_core_channel.js
import consumer from "./consumer"consumer.subscriptions.create("MatestackUiCoreChannel", {connected() {// Called when the subscription is ready for use on the server},disconnected() {// Called when the subscription has been terminated by the server},received(data) {// Called when there's incoming data on the websocket for this channelMatestackUiCore.matestackEventHub.$emit(data.event, data)}});
app/channels/matestack_ui_core_channel.rb
class MatestackUiCoreChannel < ApplicationCable::Channeldef subscribedstream_from "matestack_ui_core"enddef unsubscribed# Any cleanup needed when channel is unsubscribedendend
Broadcast the cable__created_post
event from the create
action on the posts controller
Broadcast the cable__liked_post_xyz
event from the like
action on the posts controller
app/controller/posts_controller.rb
# ...# PUT /posts/1/likedef like@post = Post.find params[:id]@post.increment(:likes_count)if @post.saveActionCable.server.broadcast('matestack_ui_core', {event: "cable__liked_post_#{@post.id}"})render json: {message: 'Post was successfully liked.'}, status: :createdelserender json: {errors: @post.errors,message: 'Post could not be liked.'}, status: :unprocessable_entityendend# POST /postsdef create@post = Post.new(post_params)if @post.saveActionCable.server.broadcast('matestack_ui_core', {event: 'cable__created_post'})render json: {message: 'Post was successfully created.'}, status: :createdelserender json: {errors: @post.errors,message: 'Post could not be created.'}, status: :unprocessable_entityendend# ...
Adjust the event name used for list rerendering
app/matestack/twitter_clone/pages/posts/index.rb
# ...def form_config_helper{for: Post.new, path: posts_path, method: :post,errors: { wrapper: { tag: :div, class: 'invalid-feedback' }, input: { class: 'is-invalid' } },success: { emit: "submitted" }}enddef post_list_partialasync rerender_on: "cable__created_post", id: "post-list" do@posts.each do |post|post_partial(post)endendend# ...
Add the adjusted event names to the per-post async config
Remove the success event emits from the actions - the event for per post-rerendering will be pushed from the server now
app/matestack/twitter_clone/pages/index.rb
# ...def post_list_partial@posts.each do |post|post_partial(post)endenddef post_partial post# async rerender_on: "liked_post_#{post.id}", id: "post-#{post.id}" doasync rerender_on: "cable__liked_post_#{post.id}", id: "post-#{post.id}" dodiv class: "mb-3 p-3 rounded shadow-sm" doheading size: 5 doplain post.usernamesmall text: post.created_at.strftime("%d.%m.%Y %H:%M")endparagraph text: post.body, class: "mb-5"# action path: like_post_path(post), method: :put, success: { emit: "liked_post_#{post.id}" } doaction path: like_post_path(post), method: :put dobutton class: "btn btn-light" doplain "Like (#{post.likes_count})"endendendendend# ...
Test the current state
Navigate to localhost:3000/posts
on one browser tab
Navigate to localhost:3000/posts
on another browser tab
Place the browser tabs side by side, so that you can see both browser tab contents
Use the form and the actions on one browser tab and see how the content on the other tab gets updated and vice versa
Wow! We just had to copy and paste a JavaScript snippet once in order to integrate ActionCable, broadcast an event from the controller action and without any more added complexity, we get synced clients, implemented in pure Ruby! Fantastic!
We will take a short break before adding the next cool reactivity feature add refactor a little bit! Matestack encourages you to create a readable and maintainable UI implemetation. Therefore we will move some complexity from the current index page to a self contained Matestack component!
Create a components folder within the matestack folder
mkdir -p app/matestack/componentstouch app/matestack/components/post.rb
Move code from the index page to the new component
app/matestack/components/post.rb
class Components::Post < Matestack::Ui::Componentrequires :postdef response# copied from the index pageasync rerender_on: "cable__liked_post_#{post.id}", id: "post-#{post.id}" dodiv class: "mb-3 p-3 rounded shadow-sm" doheading size: 5 doplain post.usernamesmall text: post.created_at.strftime("%d.%m.%Y %H:%M")endparagraph text: post.body, class: "mb-5"action path: like_post_path(post), method: :put dobutton class: "btn btn-light" doplain "Like (#{post.likes_count})"endendendendendend
Create a component registry file
touch app/matestack/components/registry.rb
Register the new component
app/matestack/components/registry.rb
module Components::RegistryMatestack::Ui::Core::Component::Registry.register_components(post_component: Components::Post,)end
Include the component registry in your application controller
app/controllers/application_controller.rb
class ApplicationController < ActionController::Baseinclude Matestack::Ui::Core::ApplicationHelperinclude Components::Registryend
Adjust the index page in order to use the new component
app/matestack/twitter_clone/posts/index.rb
class TwitterClone::Pages::Posts::Index < Matestack::Ui::Pagedef prepare@posts = Post.allenddef responsepost_form_partialpost_list_partialendprivate# ...def post_list_partialasync rerender_on: "submitted", id: "post-list" do@posts.each do |post|# post_partial(post)post_component post: postendendend# def post_partial post# async rerender_on: "cable__liked_post_#{post.id}", id: "post-#{post.id}" do# div class: "mb-3 p-3 rounded shadow-sm" do# heading size: 5 do# plain post.username# small text: post.created_at.strftime("%d.%m.%Y %H:%M")# end# paragraph text: post.body, class: "mb-5"# action path: like_post_path(post), method: :put do# button class: "btn btn-light" do# plain "Like (#{post.likes_count})"# end# end# end# end# endend
Test the current state
Navigate to localhost:3000/posts
Everything should be the same! We just refactored some code in order to better manage complexity.
Now we will cover the last topic of this guide:
As described before, the async
rerenders it's whole body. The async
wrapping the whole post list therefore rerenders ALL posts. If our list of posts grows, the performance of the rerendering will decrease. In a lot of usecases, this will not be an issue since the UI is not too big/too complex. So go ahead and use async
everywhere you're not rerendering big or complex UIs and enjoy the simplicity of that rerendering approach!
But now imagine, your post list will be too big at some point. We should switch the reactivity approach to a more granular on. Let's use the cable
component alongside our already added ActionCable introduction and reuse pretty much all written code!
Use the cable
instead of the async
component
app/matestack/twitter_clone/posts/index.rb
class TwitterClone::Pages::Posts::Index < Matestack::Ui::Pagedef prepare@posts = Post.allenddef responsepost_form_partialpost_list_partialendprivate# ...def post_list_partial# async rerender_on: "submitted", id: "post-list" docable prepend_on: "cable__created_post", id: "post-list" do@posts.each do |post|post_component post: postendendendend
Adjust the ActionCable broadcast on the create
action on the post controller
app/controller/posts_controller.rb
# ...# POST /postsdef create@post = Post.new(post_params)if @post.saveActionCable.server.broadcast('matestack_ui_core', {event: 'cable__created_post',data: matestack_component(:post_component, post: @post) # add this line})render json: {message: 'Post was successfully created.'}, status: :createdelserender json: {errors: @post.errors,message: 'Post could not be created.'}, status: :unprocessable_entityendend# ...
Test the current state
Navigate to localhost:3000/posts
Post something!
You probably don't realize any difference on the UI, but now ONLY the fresh post will be rendered on the server and pushed to the cable
component mounted in the browser. The cable
component is configured to prepend
(put on top) everything pushed from the server on the cable__created_post
event. This reactivity approach is now already much more scalable in a context of big/complex UI rerendering.
The cable
component can prepend
, append
, update
and delete
elements within its body or replace
its whole body with something pushed from the server. We want to use the update
feature in order to rerender a specific post when liked:
Add the update_on
config to the cable
config
app/matestack/twitter_clone/posts/index.rb
class TwitterClone::Pages::Posts::Index < Matestack::Ui::Pagedef prepare@posts = Post.allenddef responsepost_form_partialpost_list_partialendprivate# ...def post_list_partial# cable prepend_on: "cable__created_post", id: "post-list" docable prepend_on: "cable__created_post", update_on: "cable__liked_post", id: "post-list" do@posts.each do |post|post_component post: postendendendend
Remove the async
and add an ID to the root element
app/matestack/components/post.rb
class Components::Post < Matestack::Ui::Componentrequires :postdef response# async rerender_on: "cable__liked_post_#{post.id}", id: "post-#{post.id}" dodiv class: "mb-3 p-3 rounded shadow-sm", id: "post-#{post.id}" doheading size: 5 doplain post.usernamesmall text: post.created_at.strftime("%d.%m.%Y %H:%M")endparagraph text: post.body, class: "mb-5"action path: like_post_path(post), method: :put dobutton class: "btn btn-light" doplain "Like (#{post.likes_count})"endendend# endendend
Adjust the ActionCable broadcast on the like
action on the post controller
app/controller/posts_controller.rb
# ...# PUT /posts/1/likedef like@post = Post.find params[:id]@post.increment(:likes_count)if @post.saveActionCable.server.broadcast('matestack_ui_core', {# event: "cable__liked_post_#{@post.id}"# no id required in the event name, the cable component will figure out which post# should be updated using the root element ID of the pushed componentevent: "cable__liked_post", # change the event namedata: matestack_component(:post_component, post: @post) # add this line})render json: {message: 'Post was successfully liked.'}, status: :createdelserender json: {errors: @post.errors,message: 'Post could not be liked.'}, status: :unprocessable_entityendend# POST /postsdef create@post = Post.new(post_params)if @post.saveActionCable.server.broadcast('matestack_ui_core', {event: 'cable__created_post',data: matestack_component(:post_component, post: @post) # add this line})render json: {message: 'Post was successfully created.'}, status: :createdelserender json: {errors: @post.errors,message: 'Post could not be created.'}, status: :unprocessable_entityendend# ...
Test the current state
Navigate to localhost:3000/posts
Like something on both browser tabs
Again: you probably don't realize any difference on the UI, but now ONLY the updated post will be rendered on the server and pushed to the cable
component mounted in the browser.
The cable
component is configured to updated
the component pushed from the server on the cable__liked_post
event. The cable
component then reads the ID of the root element of the pushed component, looks for that ID within it's body and updates this element with the pushed component.
Now, we're rerendering the list and its elements completely with the cable
component. As described, this is an ALTERNATIVE approach to the introduced async
component approach. The cable
component requires a bit more implementation and brain power but makes our reactivity more scalable. Use the cable
component wherever you think async
would be too slow at some point!
Ok, let's lazy load the list of posts in order to speed up initial page load when reading the posts from the database and rendering them gets "too slow" at some point. Take a deep breath: We will use async
and cable
together now!
Relax, it's super simple:
Wrap an async
component around the cable
component
Configure this async
to defer its rendering
Move the ActiveRecord query out of the prepare
method into a helper method
app/matestack/twitter_clone/posts/index.rb
class TwitterClone::Pages::Posts::Index < Matestack::Ui::Page# def prepare# @posts = Post.all# enddef responsepost_form_partialpost_list_partialendprivate# ...def postsPost.allenddef post_list_partialasync defer: true, id: "deferred-post-list" docable prepend_on: "cable__created_post", update_on: "cable__liked_post", id: "post-list" do# @posts.each do |post|posts.each do |post|post_component post: postendendendendend
Test the current state
Navigate to localhost:3000/posts
Check the browsers network monitor and watch how a subsequent HTTP GET call resolves the posts list
Change defer: true
to defer: 1000
and see, how the subsequent call is deferred for 1000 milliseconds now
That was easy, right? The async
requested its content right after the page was loaded. We moved the ActiveRecord query out of the prepare
method out of following reason: When rendering a Matestack page/component, the prepare
method is always called. This means, the ActiveRecord query is performed on the initial page load although we don't need the data yet. Matestacks rendering mechanism stops rendering components which are wrapped in an async defer
component on initial page load and only renders them, when they are explicitly requested in a subsequent HTTP call. Therefore we should take care of calling the ActiveRecord query only from within the deferred block. In our example we accomplish this by calling the helper method posts
instead of using the instance variable @posts
, formerly resolved in the prepare
method.
Using this approach, it is super simple to speed up initial page loads without adding complexity or JavaScript to your code! Awesome!
Want some sugar? How about adding a CSS animation while lazy loading the post list?
app/javascript/packs/stylesheets/application.scss
// Async loading state.matestack-async-component-container{opacity: 1;transition: opacity 0.2s ease-in-out;&.loading {opacity: 0;}}
app/javascript/packs/application.js
// add this lineimport "./stylesheets/application.scss";
Refresh the browser and enjoy the fade effect!
Speaking of fade effects: Let's add a second page in order to show, how you can use Matestacks app and transition
component in order to implement dynamic page transitions without full browser page reload and without adding any JavaScript!
We will create a profile page in order to save the username in a session cookie rather than asking for the username on the post form! Obviously, you would use proper user management via something like devise
in a real world example!
Add an view helper method in order to access the session cookie from a Matestack page
app/helpers/cookie_helper.rb
module CookieHelperdef current_usernamecookies[:username]endend
Remove the username input from the post form
Remove the toggle components from the post index page; we will add them to the app in a bit enabling the new profile page to trigger them as well!
app/matestack/twitter_clone/posts/index.rb
class TwitterClone::Pages::Posts::Index < Matestack::Ui::Pagedef responsepost_form_partialpost_list_partialendprivatedef post_form_partialdiv class: "mb-3 p-3 rounded shadow-sm" doheading size: 4, text: "New Post", class: "mb-3"form form_config_helper do# div class: "mb-3" do# form_input key: :username, type: :text, placeholder: "Username", class: "form-control"# enddiv class: "mb-3" doform_input key: :body, type: :text, placeholder: "What's up?", class: "form-control"enddiv class: "mb-3" doform_submit dobutton type: :submit, class: "btn btn-primary", text: "Post!"endendendend# toggle show_on: "submitted", hide_after: 5000 do# div class: "container fixed-bottom w-100 bg-success text-white p-3 rounded-top" do# heading size: 4, text: "Success: {{ event.data.message }}"# end# end# toggle show_on: "form_failed", hide_after: 5000 do# div class: "container fixed-bottom w-100 bg-danger text-white p-3 rounded-top" do# heading size: 4, text: "Error: {{ event.data.message }}"# end# endend# ...end
Adjust the create action in order to use the cookie instead of a user input
app/matestack/twitter_clone/posts/index.rb
# ...# POST /postsdef create@post = Post.new(post_params)@post.username = cookies[:username] # add this# check if the username is already setif cookies[:username].blank?# if not complain!render json: {message: 'No username given!'}, status: :unprocessable_entityelse# if yes, perform the code we already gotif @post.saveActionCable.server.broadcast('matestack_ui_core', {event: 'cable__created_post',data: matestack_component(:post_component, post: @post)})render json: {message: 'Post was successfully created.'}, status: :createdelserender json: {errors: @post.errors,message: 'Post could not be created.'}, status: :unprocessable_entityendendend# ...
Add a second page
mkdir -p app/matestack/twitter_clone/pages/profiletouch app/matestack/twitter_clone/pages/profile/edit.rb
Add some code to the profile edit page
app/matestack/twitter_clone/pages/profile
class TwitterClone::Pages::Profile::Edit < Matestack::Ui::Pagedef responsediv class: "mb-3 p-3 rounded shadow-sm" doheading size: 4, text: "Your Profile", class: "mb-3"form form_config_helper dodiv class: "mb-3" doform_input key: :username, type: :text, placeholder: "Username", class: "form-control", init: current_usernameenddiv class: "mb-3" doform_submit dobutton type: :submit, class: "btn btn-primary", text: "Save!"endendendendendprivatedef form_config_helper{for: :profile, path: profile_update_path, method: :put,success: { emit: "submitted" },failure: { emit: "form_failed" },errors: { wrapper: { tag: :div, class: 'invalid-feedback' }, input: { class: 'is-invalid' } }}endend
Add the profile
routes:
config/routes.rb
Rails.application.routes.draw doresources :posts domember doput 'like', to: 'posts#like'endendscope :profile, as: :profile doget 'edit', to: 'profile#edit'put 'update', to: 'profile#update'endend
Add the profile
controller:
touch app/controllers/profile_controller.rb
app/controllers/profile_controller.rb
class ProfileController < ApplicationControllermatestack_app TwitterClone::App# GET /profile/editdef editrender TwitterClone::Pages::Profile::Editend# PUT /profile/updatedef updateif profile_params[:username].blank?render json: {message: 'Profile could not be updated.',errors: { username: ["can't be blank!"] }}, status: :unprocessable_entityelsecookies[:username] = profile_params[:username]render json: {message: 'Profile was successfully updated.'}, status: :createdendendprivate# Only allow a list of trusted parameters through.def profile_paramsparams.require(:profile).permit(:username)endend
Add transition
components to the app
Add the toggle
components from the post index page to the app, this way they can be triggered from all pages
app/matestack/twitter_clone/app.rb
class TwitterClone::App < Matestack::Ui::Appdef responsediv class: "container" do# heading size: 1, text: "Twitter Clone", class: "mb-5"# yield_pageheading size: 1, text: "Twitter Clone"transition path: posts_path dobutton class: "btn btn-light", text: "Timeline"endtransition path: profile_edit_path dobutton class: "btn btn-light", text: "Your Profile"enddiv class: "mt-5" doyield_pageend# add the toggle components here, this way all pages are able to trigger them!toggle show_on: "submitted", hide_after: 5000 dodiv class: "container fixed-bottom w-100 bg-success text-white p-3 rounded-top" doheading size: 4, text: "Success: {{ event.data.message }}"endendtoggle show_on: "form_failed", hide_after: 5000 dodiv class: "container fixed-bottom w-100 bg-danger text-white p-3 rounded-top" doheading size: 4, text: "Error: {{ event.data.message }}"endendendendend
Test the current state
Navigate to localhost:3000/posts
Click on "Your Profile"
See how the page is updated without a full browser page reload
See how the url changed according to our routes and realize that we're using plain Rails routing for page transitions
Enter a username there
Realize, that we used the form for a non ActiveRecord data structure
Click on "Timeline"
Again: see how the page is updated without a full browser page reload, maybe even inspect your browsers network monitor ;)
Post something and enjoy not to enter a username anymore (use a private tab if you want to act as a different user!)
Great, we just added a second page and added some transition
components to our app and without further effort, we implemented dynamic page transitions without touching any JavaScript. The transition
component triggered the app to request the desired page at the server targeting the appropriate controller action through Rails routing and adjusted the DOM where we placed the yield_page
on our app!
And you know what: let's add some CSS animations!
Add some basic animations to your stylesheets (SCSS)
app/javascript/packs/stylesheets/application.scss
// Async loading state.matestack-async-component-container{opacity: 1;transition: opacity 0.2s ease-in-out;&.loading {opacity: 0;}}// Page loading state.matestack-page-container{.matestack-page-wrapper {opacity: 1;transition: opacity 0.2s ease-in-out;&.loading {opacity: 0;}}}
Add delays to the transition
components; otherwise we probably won't see the animations!
app/matestack/twitter_clone/app.rb
class TwitterClone::App < Matestack::Ui::Appdef responsediv class: "container" doheading size: 1, text: "Twitter Clone"# transition path: posts_path dotransition path: posts_path, delay: 300 dobutton class: "btn btn-light", text: "Timeline"end# transition path: profile_edit_path dotransition path: profile_edit_path, delay: 300 dobutton class: "btn btn-light", text: "Your Profile"enddiv class: "mt-5" doyield_pageendendendend
Test the current state
Navigate to localhost:3000/posts
Click on the transition components
Enjoy the fade effects once again :)
And now we do something, what's not possible in Twitter: Editing. Tweets. Inline. In pure Ruby! (Just because it's nice to showcase that)
Add an edit form
Add an onclick
component emit an event indicating that we want to show the form now
Wrap your code into toggle components, switching the currently visible content
app/matestack/components/post.rb
class Components::Post < Matestack::Ui::Componentrequires :postdef responsediv class: "mb-3 p-3 rounded shadow-sm", id: "post-#{post.id}" doheading size: 5 doplain post.usernamesmall text: post.created_at.strftime("%d.%m.%Y %H:%M")endtoggle hide_on: "edit-post-#{post.id}", show_on: "updated", init_show: true doshow_partialendtoggle show_on: "edit-post-#{post.id}", hide_on: "updated" doedit_partialend# paragraph text: post.body, class: "mb-5"# action path: like_post_path(post), method: :put do# button class: "btn btn-light" do# plain "Like (#{post.likes_count})"# end# endendendprivatedef show_partialparagraph text: post.body, class: "mb-5"action path: like_post_path(post), method: :put dobutton class: "btn btn-light" doplain "Like (#{post.likes_count})"endend# onclick emits an event triggering the toggle components to show/hide# we use Bootstraps "d-inline" utility class here because onclick renders# a block element (will be changed to an inline element in a future release)onclick emit: "edit-post-#{post.id}", class: "d-inline" dobutton class: "btn btn-link" doplain "Edit"endendenddef edit_partialform form_config_helper dodiv class: "mb-3" doform_input key: :body, type: :text, placeholder: "What's up?", class: "form-control"enddiv class: "mb-3" doform_submit dobutton type: :submit, class: "btn btn-primary", text: "Update!"endendendenddef form_config_helper{for: post, path: post_path(id: post.id), method: :put,success: { emit: "updated" },failure: { emit: "form_failed" },errors: { wrapper: { tag: :div, class: 'invalid-feedback' }, input: { class: 'is-invalid' } }}endend
Add the update action to the posts controller
app/controller/posts_controller.rb
# ...# PUT /posts/1def update@post = Post.find(params[:id])if @post.update(post_params)ActionCable.server.broadcast('matestack_ui_core', {event: "cable__updated_post",data: matestack_component(:post_component, post: @post)})render json: {message: 'Post was successfully updated.'}, status: :createdelserender json: {errors: @post.errors,message: 'Post could not be updated.'}, status: :unprocessable_entityendend# ...
Test the current state
Navigate to localhost:3000/posts
Click on the edit button on a Tweet
Change the value and submit
Do it again. And again!
Party around! You've reached the end of the tutorial!
We've built a reactive Twitter clone in pure Ruby. Fantastic! :)
Like it? Consider giving the project a star or even become a sponsor on Github, share it with your friends and colleagues (and family?) and join our Discord server! We're super happy about feedback, looking forward to hear your success stories and help you to build awesome things with Matestack!