Notion had for a long-time a neat block-based editor. It allows you to type away with a paragraph-element by default, but allows you to choose other block elements, like h1, ul and so on. This allows you to style the elements inline as well, keeping things super clear.
This article shows you the basic data modeling and logic to set up. Enhancing it with JavaScript (Stimulus) and making things pretty will happen in a following article. Exactly how I would do it in real apps too.
Data model
I am keeping this set up basic, with just a page model to hold the blocks, but in a real application a page might belong to something like a Collection. I am also not adding all possible blocks, but feel free to reach out if you need specific guidance.
First the Page model:
rails g model Page
# Let's add the association already into app/models/page.rb
class Page < ApplicationRecord
has_many :blocks, dependent: :destroy
end
Simple enough. For the Blocks I will be using DelegatedType. This allows you to have shared attribute in one Block model, while having specific Block attributes in their own. It works perfect for the editor.
rails g model Block page:belongs_to blockable:belongs_to{polymorphic}
Then the different Blocks:
rails generate model Block::Text content:text
rails generate model Block::Heading level:integer content:string
Let’s run rails db:migrate
and make some changes to the the model files:
# app/models/block.rb
class Block < ApplicationRecord
belongs_to :page
delegated_type :blockable, types: %w[Block::Text Block::Heading]
end
# app/models/block/text.rb
class Block::Text < ApplicationRecord
has_one :block, as: :blockable, dependent: :destroy
end
# app/models/block/heading.rb
class Block::Heading < ApplicationRecord
has_one :block, as: :blockable, dependent: :destroy
validates :level, inclusion: {in: 1..6}
end
Let’s also seed the database so we have some semi-real data to look at:
# db/seeds.rb
page = Page.create
blocks = [
{ type: "Block::Heading", attributes: { level: 1, content: "Welcome to Rails Wonderland" } },
{ type: "Block::Text", attributes: { content: "Once upon a time, in a land full of gems, there was a brave developer named Ruby." } },
{ type: "Block::Heading", attributes: { level: 2, content: "The Quest for the Perfect Gem" } },
{ type: "Block::Text", attributes: { content: "Ruby embarked on a quest to find the perfect gem, one that would solve all N+1 queries." } },
{ type: "Block::Heading", attributes: { level: 3, content: "Enter the Realm of Active Record" } },
{ type: "Block::Text", attributes: { content: "In the mystical realm of Active Record, Ruby learned the ancient art of associations." } },
{ type: "Block::Heading", attributes: { level: 3, content: "The Trials of Migration" } },
{ type: "Block::Text", attributes: { content: "With every migration, Ruby grew stronger, mastering the power of schema changes." } },
{ type: "Block::Text", attributes: { content: "And thus, the legend of Ruby and the Rails was born, inspiring developers across the world." } }
]
blocks.each do |block_data|
blockable = block_data[:type].constantize.create(block_data[:attributes])
Block.create(page: page, blockable: blockable)
end
And run it: rails db:seed
.
And finally a basic route, controller + view and partials:
# config/routes.rb
Rails.application.routes.draw do
resources :pages, only: %w[show]
end
# app/controllers/pages_controller.rb
class PagesController < ApplicationController
def show
@blocks = Page.find(params[:id]).blocks
end
end
# app/views/pages/show.html.erb
<%= render @blocks %>
# app/views/blocks/_block.html.erb
<%= render "blocks/blockable/#{block.blockable_name}", block: block %>
# app/views/blocks/blockable/_block_heading.html.erb
<%= content_tag "h#{block.block_heading.level}", block.block_heading.content %>
# app/views/blocks/blockable/_block_text.html.erb
<%= content_tag :p, block.block_text.content %>
Wow, that was a lot. But if you navigate to http://localhost:3000/pages/1 you should see the rendering of your first block-based page. Yay! 🎉
Basic editor
With the basic modeling in place and being able to render the page’s blocks, let’s make the page editable.
First the route, controller and view:
# config/routes.rb
Rails.application.routes.draw do
resources :pages, only: %w[show edit] do
resources :blocks, module: :pages, only: %w[create update]
end
end
# app/controllers/pages_controller.rb
class PagesController < ApplicationController
before_action :set_page, only: %w[show edit]
def show
@blocks = @page.blocks
end
def edit
end
private
def set_page
@page = Page.find(params[:id])
end
end
# app/views/pages/edit.html.erb
<ol id="blocks">
<%= render partial: "pages/block", collection: @page.blocks %>
</ol>
# app/views/pages/_block.html.erb
<li>
<%= form_with model: [block.page, block] do |form| %>
<%= form.fields_for :blockable do |blockable_form| %>
<%= render "blocks/editable/#{block.blockable_type.underscore}", form: blockable_form %>
<% end %>
<%= form.submit %>
<% end %>
</li>
Because each block has its own “blockable” which in turn can have different fields for each, let’s create a different partial for each:
# app/views/blocks/editable/block/_heading.html.erb
<%= form.text_field :level %>
<br>
<%= form.text_area :content %>
# app/views/blocks/editable/block/_text.html.erb
<%= form.text_area :content %>
Let’s continue by also allowing to actually update ánd create new blocks as well:
# app/controllers/pages/blocks_controller.rb
class Pages::BlocksController < ApplicationController
before_action :set_page, only: %w[create update]
def create
@page.blocks.create!(
blockable: params[:blockable_type].constantize.new(new_block_params)
)
redirect_to edit_page_path(@page)
end
def update
Block.find(params[:id]).update(existing_block_params)
redirect_to edit_page_path(@page)
end
private
def set_page
@page = Page.find(params[:page_id])
end
def new_block_params
params.permit(blockable_attributes: [:level])[:blockable_attributes].to_h.compact_blank
end
def existing_block_params
params.require(:block).permit(:id, blockable_attributes: [:id, :level, :content])
end
end
Now let’s add a few buttons to create a new block. Update the pages#edit page:
<ol id="blocks">
<%= render partial: "pages/block", collection: @page.blocks %>
</ol>
<%= button_to "Add paragraph", page_blocks_path(@page), params: {blockable_type: "Block::Text"} %>
<%= button_to "Add h1",
page_blocks_path(@page),
params: {
blockable_type: "Block::Heading",
blockable_attributes: {level: 1}
}
%>
<%= button_to "Add h2",
page_blocks_path(@page),
params: {
blockable_type: "Block::Heading",
blockable_attributes: {level: 2}
}
%>
<%# etc %>
This is what you should have now:
What’s next?
Now all the basics are in place. New blocks can be created and existing blocks can be updated. In an upcoming article I want to expand on this foundation and add Turbo and a small Stimulus controller to improve the UX and also include Tailwind CSS to make things look a fair bit better.
Stay tuned! 👂