Building a persistent in-line editing experience with Crystal, MongoDB (datanoise / sam0x17)

As documentation is still sparse, I would like to add some.

This is what I am building currently:

image

The individual fields are going to be editable with an inline editor, which will automatically save to the backend – no need for reloading the whole page.

image

The point of the whole is a backend for my company, to be able to automate some processes, calculate stock prices, keep things in stock, etc. A warehouse application basically, tailored to me!

Technologies used

Important bits

image

MongoORM uses _id as the main index. Please note the underscore!

The _id is NOT a string. It is of the type ObjectId

Therefore it will need to be constructed. If you want to construct it from a previous id, use this code:

mongo_id = BSON::ObjectId.new my_string_id

where my_string_id is a String, and mongo_id is an ObjectId.

The code

config/database.yml

is responsible for setting up the communication to your Mongo DB instance. Please refer to Sam’s documentation.

stock.cr, responsible for defining Stockitem, Subitem, and the apropriate Kemal handlers:

require “mongo_orm”
require “json”
require “./macros.cr”
require “./weee.cr”

module Stock
     include Macros

    class Itemgroup < Mongo::ORM::Document #used for grouping, e.g. Pimoroni products, Shipping, etc.
         field group : String
         has_many :stockitems #note that the stockitem can only belong to one ItemGroup at a time.
     end

    #this includes other items as well (shipping, digital downloads, etc)
     class Stockitem < Mongo::ORM::Document
         field sku : String
         field description : String
         field is_alias : Bool
         field alias_of : String #if this field is set, do not use any other definitions from this item, get the aliased master item
         field is_set : Bool # if this item is a set of other items
         embeds_many :subitems # should only be set if is_set = true
         field is_physical_item : Bool
         field weee_applicable : Bool #can be either: paid for by someone else, or item is not electronics, …
         field weight : Int32 # in g
         timestamps
     end

    class Subitem < Mongo::ORM::EmbeddedDocument
         field sku : String #a reference to the main stock item. NB: this could probably be reworked in a nicer fashion with belongs_to ? e.g. belongs_to :itemref, class_name: Stock::Stockitem
         field note : String
         field amount : Int32
     end

    get “/stock/show” do |env|
         #https://github.com/datanoise/mongo.cr
         sis = Stockitem.all({“is_alias” => {“$eq” => false}})
         name = “Stock :: Show &amp; Edit”
         mrender “stock”
     end

    post “/stock/edit/:what” do |env|
         pp “Entering edit, with target ” + env.params.url[“what”]
         pp env.params.body
         if env.params.body.has_key?(“pk”) && env.params.body.has_key?(“value”)
             pp “Inside pk & value”
             pk = env.params.body[“pk”]?.as(String)
             value = env.params.body[“value”]?.as(String)
             if si = Stockitem.find(BSON::ObjectId.new pk)   
                 case env.params.url[“what”]
                 when “sku”
                     si.sku = value
                 when “description”
                     si.description = value
                 when “is_alias”
                     si.is_alias = value == “true” ? true : false
                 when “alias_of”
                     si.alias_of = value
                 when “is_set”
                     si.is_set = value == “true” ? true : false
                 when “is_physical_item”
                     si.is_physical_item = value == “true” ? true : false
                 when “weee_applicable”
                     pp “weee_applicable”
                     si.weee_applicable = value == “true” ? true : false
                 end
                
                 if si.save
                     pp “inside si.save”
                     env.response.status_code = 200
                 else
                     env.response.status_code = 400
                 end
             else
                 env.response.status_code = 400   
             end
         else
             env.response.status_code = 400
         end
     end

    #generate from WEEE entries
     get “/stock/generate/:year” do |env|
         #walk through the entire database, collect the items, and display a list
         stock_collection = Hash(String,String).new
         invoices = WEEE::Invoice.all({“year”=>env.params.url[“year”].to_i32})       
         invoices.each do |invoice|
             #p “processing invoice #{invoice.number}”
             #note that we might not get absolutely all invoices here, as easybill has a different view of the dates (import)
             invoice.items.each do |item|
                 if !item.description.nil? && !item.number.nil?
                     key = item.number == “” ? item.description : item.number
                     if !key.nil?
                         if !stock_collection.has_key?(key)
                             description = “”
                             if !item.description.nil?
                                 description = item.description
                             end                   
                             #stock_collection[key] = item.description
                             if !description.nil?
                                 stock_collection[key] = description
                             end
                         end # end of !stock_collection.has_key?
                     end # end of if !key.nil?
                 end
             end #end of invoice.items.each do
         end #end of invoices.each
         #check that this does not exist already in our database, else create it.
         stock_collection.each do |sku,description|
             if !Stockitem.find_by(:sku, sku)
                 si = Stockitem.new
                 si.sku = sku
                 si.is_alias = false
                 si.alias_of = “”
                 si.description = description
                 si.is_set = false
                 si.is_physical_item = true
                 si.weee_applicable = true
                 si.weight = 100
                 si.save!
             end
         end
     end #end of get macro

end #end of module

Notes:

  • Sam’s documentation explains parts of this code, I will only address the stumbling blocks:
  • sis = Stockitem.all({“is_alias” => {“$eq” => false}}) – this code searches for Stockitems (Mongo ORM will take care of the DB name for you, etc.)
  • si = Stockitem.find(BSON::ObjectId.new pk)
    • note that here BSON::ObjectId.new is used to reconstruct the id from the key pk which was passed
  • return code 200 on success and 400 on failure is for x-editable to know whether the edit succeeded

Behaviour of si.save

  • si.save! will raise an error if saving is not possible
  • si.save will return true if saving was possible, and false if it was not possible

stock.ecr, the HTML template

<% content_for “name” do %>
     <%= name %>
<% end %>

<% content_for “main” do%>
<div class=”col-md-12″>
     <div class=”box box-primary”>
         <div class=box-header with-border”>
             <h3 class=”box-title”><%= name %></h3>
         </div>
         <div class=”box-body”>
             Please note: alias’ed items are not loaded from the database.
             <TABLE class=”table table-bordered table-striped dataTable” role=”grid” aria-describedby=”Table with stock items” id=”stock_items”>
                 <thead>
                     <tr role=”row”>
                         <th class=”sorting_asc” tabindex=”0″>sku</th>
                         <th>description</th>
                         <th>is_alias?</th>
                         <th>alias_of</th>
                         <th>is_set?</th>
                         <th>subitems</th>
                         <th>is_physical_item?</th>
                         <th>weee_applicable?</th>
                         <th>weight (g)</th>
                     </tr>
                 </thead>
                 <% sis.each do |si| %>
                 <TR role=”row”>
                     <TD><%= si.sku.to_s %></TD>
                     <TD><%= si.description.to_s %></TD>
                     <TD><%= si.is_alias ? “true” : “false” %></TD>
                     <TD><%= si.alias_of.to_s %></TD>
                     <TD><%= si.is_set ? “true” : “false” %></TD>
                     <TD>tbd.</TD>
                     <TD><%= si.is_physical_item ? “true” : “false” %></TD>
                     <TD><A HREF=”#”
                             id=”<%= si._id.to_s.rchop %>.weee_applicable”
                             class=”weee_applicable editable”
                             data-type=”select”
                             data-pk=”<%= si._id.to_s.rchop %>”
                             data-value=”true”
                             data-source=”[{value: ‘true’, text: ‘true’}, {value: ‘false’, text: ‘false’}]”
                             data-url=”/stock/edit/weee_applicable”
                             data-title=”WEEE: <%= si.sku.to_s %>”
                         ><%= si.weee_applicable ? “true” : “false” %></A></TD>
                     <TD><%= si.weight.to_s %> g</TD>
                 </TR>
                 <% end %>

            </TABLE>
         </div>
     </div>
</div>
<script>
     //https://datatables.net/examples/styling/bootstrap
     //demo.js x-editable
     window.onload = function() {
         $(document).ready(function (){
             $(“#stock_items”).DataTable();
             $.fn.editable.defaults.mode = ‘popup’;
             $(“.editable”).editable();
             $(“.editable”).on(‘hidden’, function(e, reason){
                 if(reason === ‘save’ || reason === ‘nochange’){
                     var $next = $(this).closest(‘tr’).next().find(‘.editable’);
                     setTimeout(function(){
                         $next.editable(‘show’);
                     }, 300);
                 }
             });
         });
     }
</script>
<% end %>

Notes:

  • since we add our JS scripts after this (I should rework this to add a custom script section at the end), we have to wrap them in a window.onload function – otherwise $ is not defined (jQuery shortcut)
  • note how we use a function here to jump to the next editable on save success
  • si._id.to_s.rchop – the BSON id is translated to a string, and then the last weird character (null terminator?) is chopped off. This is an important stumbling block!

layout.ecr (excerpts)

<!DOCTYPE html>
<html>
<head>
   <meta charset=”utf-8″>
   <meta http-equiv=”X-UA-Compatible” content=”IE=edge”>
   <title>TaxGod | <%= yield_content “name” %></title>
   <meta content=”width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no” name=”viewport”>
   <link rel=”stylesheet” href=”/bower_components/bootstrap/dist/css/bootstrap.min.css”>
   <link rel=”stylesheet” href=”/bower_components/font-awesome/css/font-awesome.min.css”>
   <link rel=”stylesheet” href=”/bower_components/Ionicons/css/ionicons.min.css”>
   <link rel=”stylesheet” href=”/dist/css/AdminLTE.min.css”>
   <link rel=”stylesheet” href=”/dist/css/skins/skin-blue.min.css”>
   <link rel=”stylesheet” href=”/danielm_uploader/css/jquery.dm-uploader.min.css”>
   <link rel=”stylesheet” href=”/bower_components/datatables.net-bs/css/dataTables.bootstrap.min.css”>
   <link rel=”stylesheet” href=”/tg_css/bootstrap-editable.css”>
   <link rel=”stylesheet” href=”/tg_css/picockpit.css”>
   <link rel=”stylesheet” href=”/tg_css/dropzone.css”> 
   <!–[if lt IE 9]>
   <script src=”https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js”></script>
   <script src=”https://oss.maxcdn.com/respond/1.4.2/respond.min.js”></script>
   <![endif]–>

  <link rel=”stylesheet”
         href=”https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,600,700,300italic,400italic,600italic”>
</head>
<body class=”hold-transition skin-blue sidebar-mini”>
<div class=”wrapper”>

(…)

  <!– Content Wrapper. Contains page content –>
   <div class=”content-wrapper”>
     <!– Content Header (Page header) –>
     <section class=”content-header”>
       <h1>
         TaxGod :: <%= yield_content “name” %>
         <small>Being God is fun.</small>
       </h1>
     </section>

    <!– Main content –>
     <section class=”content container-fluid”>

     <%= yield_content “main” %>

    </section>
     <!– /.content –>
   </div>
   <!– /.content-wrapper –>

(…)
</div>
<!– ./wrapper –>

<!– REQUIRED JS SCRIPTS –>

<!– jQuery 3 –>
<script src=”/bower_components/jquery/dist/jquery.min.js”></script>
<!– Bootstrap 3.3.7 –>
<script src=”/bower_components/bootstrap/dist/js/bootstrap.min.js”></script>
<!– AdminLTE App –>
<script src=”/danielm_uploader/js/jquery.dm-uploader.min.js”></script>
<script src=”/bower_components/datatables.net/js/jquery.dataTables.min.js”></script>
<script src=”/bower_components/datatables.net-bs/js/dataTables.bootstrap.min.js”></script>
<script src=”/dist/js/adminlte.min.js”></script>
<script src=”/tg_js/bootstrap-editable.min.js”></script>
<script src=”/tg_js/dropzone.min.js”></script>
</body>
</html>

Further references