Super simple Posts with Categories for Solidus (Ruby on Rails)

Created at: 04 Nov 2022 Last updated: 24 Apr 2023

Having a way to communicate with customers on-the-fly without the need to contact a designer or programmer to modify files for you is an essential feature of any online commerce site. Solidus is agnostic about how to do this, so there are literally infinite ways you could add Posts to your Solidus EC site. But, I saw a post on the Solidus slack regarding this, and I thought it may be worth sharing how we went about it.
First you'll want to generate a migration to create the columns in the database. 

For this example we'll generate the migration only. That is, without generating the scaffold for models or controllers. But, you could also generate the scaffold and move those files into their respective folders corresponding to their inheritance (eg. PostsController will need to be moved to 'app/controllers/spree/', and its views will be in 'app/views/spree/posts/'). 

But, we can see here already that there's some design assumptions being made. In my case our shop's landing is at the base domain, not at a subdomain (yourshop.com vs yourshop.com/shop)—but this may not be the case for you. If you're not that concerned about integrating the Post editing functionality with the Solidus core backend, it may be worth considering a different setup entirely.

But I digress, to the migrations:
rails g migration CreatePosts title:string tags:string
I also want some categories for the posts, so we'll create that, too. (I'm not going to set up taxonomy or anything too complicated in this example, so it'll just be one layer.)
rails g migration CreateCategories title:string
And we'll need a join for those database tables:
rails g CreatePostsAndCategoriesJoin
Now, we've got to edit the migrations a little bit, making the tags an array and adding the appropriate columns for the join table. 
class CreatePosts < ActiveRecord::Migration[6.1]
  def change
    create_table :posts do |t|
      t.string :title
      t.string :tags, array: true, default: []

      t.timestamps
    end
  end
end

class CreateCategories < ActiveRecord::Migration[6.1]
  def change
    create_table :categories do |t|
      t.string :title

      t.timestamps
    end
  end
end

class CreatePostsAndCategoriesJoin < ActiveRecord::Migration[6.1]
  def change
    create_table :posts_categories, id: false do |t|
      t.belongs_to :post, index: true
      t.belongs_to :category, index: true
    end
  end
end
After [rails db:migrating]-ing those. We'll fill in the routes, make the controllers, views, and then add a button to the Solidus admin panel. First the routes:
Rails.application.routes.draw do
  # This line mounts Solidus's routes at the root of your application.
  # This means, any requests to URLs such as /products, will go to Spree::ProductsController.
  # If you would like to change where this engine is mounted, simply change the :at option to something different.
  #
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
  #
  # We ask that you don't use the :as option here, as Solidus relies on it being the default of "spree"

  mount Spree::Core::Engine, at: '/'
  Spree::Core::Engine.routes.draw do
    scope :admin do
      resources :categories
      resources :posts
    end
    # You may or may not have other things here, 
    # but this is the basic way to correctly scope within the Spree engine.
  end
end
Next We'll set up the controllers. The key here is that I've created a store base controller to inherit Spree helpers and the Solidus (Spree) core admin layout, then we hook into that in the individual controllers through inheritance.
# app/controllers/store_base_controller.rb
class StoreBaseController < Spree::BaseController
  include Spree::Core::ControllerHelpers::Auth
  include Spree::Core::ControllerHelpers::Store
  include Spree::Core::ControllerHelpers::Order

  layout 'spree/layouts/admin'
end

# app/controllers/spree/posts_controller.rb
class Spree::PostsController < StoreBaseController
  before_action :set_post, only: %i[show edit update destroy]
  before_action :authenticate_spree_user!, only: %i[new create edit update destroy]
  before_action :check_status, only: %i[new create edit update destroy]

  # GET /posts or /posts.json
  def index
    @posts = Post.all
    @categories = Category.all
  end

  # GET /posts/1 or /posts/1.json
  def show; end

  # GET /posts/new
  def new
    @post = Post.new
  end

  # GET /posts/1/edit
  def edit; end

  # POST /posts or /posts.json
  def create
    @post = Post.new(post_params)

    respond_to do |format|
      if @post.save
        format.html { redirect_to post_url(@post), notice: t('notice.model_create', model: t_model) }
        format.json { render :show, status: :created, location: @post }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @post.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /posts/1 or /posts/1.json
  def update
    respond_to do |format|
      if @post.update(post_params)
        format.html { redirect_to post_url(@post), notice: t('notice.model_update', model: t_model) }
        format.json { render :show, status: :ok, location: @post }
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @post.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /posts/1 or /posts/1.json
  def destroy
    @post.destroy

    respond_to do |format|
      format.html { redirect_to posts_url, notice: t('notice.model_destroy', model: t_model) }
      format.json { head :no_content }
    end
  end

  private

  # Use callbacks to share common setup or constraints between actions.
  def set_post
    @post = Post.find(params[:id])
  end

  # Only allow a list of trusted parameters through.
  def post_params
    params.require(:post).permit(:title, :content, :tag_list, categories: [], category_ids: [])
  end
end

# app/controllers/spree/categories_controller.rb
class Spree::CategoriesController < StoreBaseController
  before_action :set_category, only: %i[show edit update destroy]
  before_action :authenticate_spree_user!, only: %i[new create edit update destroy]
  before_action :check_status, only: %i[new create edit update destroy]

  # GET /categories or /categories.json
  def index
    @categories = Category.all
  end

  # GET /categories/1 or /categories/1.json
  def show; end

  # GET /categories/new
  def new
    @category = Category.new
  end

  # GET /categories/1/edit
  def edit; end

  # POST /categories or /categories.json
  def create
    @category = Category.new(category_params)

    respond_to do |format|
      if @category.save
        format.html { redirect_to category_url(@category), notice: t('notice.model_create', model: t_model) }
        format.json { render :show, status: :created, location: @category }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @category.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /categories/1 or /categories/1.json
  def update
    respond_to do |format|
      if @category.update(category_params)
        format.html { redirect_to category_url(@category), notice: t('notice.model_update', model: t_model) }
        format.json { render :show, status: :ok, location: @category }
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @category.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /categories/1 or /categories/1.json
  def destroy
    @category.destroy

    respond_to do |format|
      format.html { redirect_to categories_url, notice: t('notice.model_destroy', model: t_model) }
      format.json { head :no_content }
    end
  end

  private

  # Use callbacks to share common setup or constraints between actions.
  def set_category
    @category = Category.find(params[:id])
  end

  # Only allow a list of trusted parameters through.
  def category_params
    params.require(:category).permit(:title, posts: [], post_ids: [])
  end
end
Now we've got to create views for all these controller actions. For brevity, I'm not going to copy and base every single file (which I mentioned before can be generated with the 'rails g scaffold' command), but just show a really simple index I set up for testing which includes Posts and Categories and their related actions. 

The 'admin breadcrumb' controls what appears at the top left of the admin interface next to your store logo. The buttons that appear at the top right of the interface are accessed via '<% content_for :page_actions %>', which is not shown in the example below.
# app/views/spree/posts/index.html.erb

<% admin_breadcrumb(Post.model_name.human) %>
<% content_for :page_actions do %>
  <li>
    <%= link_to t('new'), new_post_path, class: "btn btn-primary" %>
  </li>
<% end %>

<div class="mt-5 container">
  <% if notice.present? %>
    <p class="" id="notice"><%= notice %></p>
  <% end %>

  <div class="d-flex justify-content-between mb-4">
    <div class="d-block">
      <h1> <%= Post.model_name.human %> </h1>
    </div>
    <div class="d-block">
      <%#  %>
    </div>
  </div>

  <div id="posts" class="mb-4">
    <% @posts.each do |post| %>
      <div id="<%= dom_id post %>" class="row">
        <div class="col">
          <strong class="d-block"><%= Post.human_attribute_name(:title) %></strong>
          <%= post.title %>
        </div>

        <div class="col">
          <strong class="d-block"><%= Post.human_attribute_name(:content) %></strong>
          <%= post.content.to_plain_text.truncate(70) %>
        </div>

        <div class="col">
          <strong class="d-block"><%= Post.human_attribute_name(:tags) %></strong>
          <%= post.tags.join(', ') %>
        </div>

        <div class="col text-right">
          <% if action_name != "show" %>
            <%= link_to t('show'), post_path(post, locale: I18n.locale), class: "btn btn-sm btn-primary btn mx-1" %>
            <%= link_to t('edit'), edit_post_path(post, locale: I18n.locale), class: "btn btn-sm btn-primary btn mx-1" %>
            <%= link_to t('destroy'), post_path(post), method: :delete, class: "btn btn-sm btn-warning mx-1", data: { confirm: t('confirm') } %>
          <% end %>
        </div>
      </div>
    <% end %>
  </div>

  <hr>

  <div class="d-flex justify-content-between mb-4">
    <div class="d-block">
      <h1> <%= Category.model_name.human %> </h1>
    </div>
    <div class="d-block">
      <%= link_to t('new'), new_category_path, class: "btn btn-primary" %>
    </div>
  </div>
  
  <div id="categories" class="mb-4">
    <% @categories.each do |category| %>
      <div id="<%= dom_id category %>" class="row">
        <div class="col">
          <strong class="d-block"><%= Category.human_attribute_name(:title) %></strong>
          <%= category.title %>
        </div>
        <div class="col">
          <strong class="d-block"><%= Category.human_attribute_name(:slug) %></strong>
          <%= category.slug %>
        </div>
        <div class="col">
          <strong class="d-block"><%= Post.model_name.human %></strong>
          <%= category.posts.count %>
        </div>
        <div class="col text-right">
          <%= link_to t('edit'), edit_category_path(category, locale: I18n.locale), class: "btn btn-sm btn-primary btn mx-1" %>
          <%= link_to t('destroy'), category_path(category), method: :destroy, class: "btn btn-sm btn-warning mx-1" %>
        </div>
      </div>
    <% end %>
  </div>
</div>

And finally, here's how to add a button to the admin panel. Without the 'condition,' the menu item will be shown to all users, even, for example, on the login page itself for the admin panel--so it's a pretty crucial piece of code. But, it can be modified for other rules, such as users with different levels of authorization. There's also an option for 'position' for where you want the menu item to show in the list, you can read more about this from this StackOverflow post I made about it, too.
Spree::Backend::Config.configure do |config|

  # Uncomment and change the following configuration if you want to add
  # Menu items use icons from fontawesome v4, so you can pick one from here:
  # https://fontawesome.com/v4/icons/
  config.menu_items << config.class::MenuItem.new(
    [:posts],
    'file-text-o',
    url: :posts_path,
    condition: -> { can?(:manage, Spree::Order) }
Now, when you create posts you can include them in your frontend. Here's what our shop index, using Tailwind for css, looks like:
<div class="p-2">
  <% if params[:keywords] %>

    <div data-hook="search_results">
      <% if @products.empty? %>
        <h6 class="search-results-title"><%= t('spree.no_products_found') %></h6>
      <% else %>
        <%= render partial: 'spree/shared/products', locals: { products: @products, taxon: @taxon } %>
      <% end %>
    </div>

  <% else %>
    <div class="text-xl md:text-2xl xl:text-3xl prose py-4 md:py-8 underline decoration-slate-300 decoration-wavy decoration-1 tracking-wider">
      <%= t('spree.new_posts') %>
    </div>
    <div class="grid grid-cols-1 lg:grid-cols-2 gap-8 py-4">
      <% Post.last(2).each do |post| %>
        <div class="col">
          <div class="text-base md:text-lg lg:text-xl pb-4">
            <%= post.title %>
          </div>
          <div class="text-sm lg:text-base prose">
            <%= post.content %>
          </div>
          <div class="font-light text-sm text-neutral-400">
            <%= post.created_at.strftime("%B %d, %Y") %>
          </div>
        </div>
      <% end %>
    </div>
    <hr class="pt-2 xl:text-3xl mt-2">

    <div data-hook="homepage_products">
      <div class="text-xl lg:text-2xl xl:text-3xl prose py-4 md:py-8 underline decoration-slate-300 decoration-wavy decoration-1 tracking-wider">
        <%= t('spree.popular_products') %>
      </div>
      <%= render partial: 'spree/shared/products', locals: { products: @products, taxon: @taxon } %>
    </div>
  <% end %>
</div>

I'm an intermediate level programmer, so use these with caution, but hopefully these snippets may be helpful to someone out there. I also haven't enabled commenting functionality on this blog yet, but I welcome feedback or comments on Twitter.

Back