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.