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:
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.
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
- Crystal
- Kemal
- AdminLTE 2
- Bootstrap 3
- dataTables
- x-editable – a really awesome library for inline editing, sadly not mantained for some time
- see examples for Bootstrap 3 here
- MongoDB
- Mongo ORM by sam0x17
- which in turn is based off datanoise’s Mongo Driver for Crystal (mongo.cr)
- Sublime Text for editing
- Robo 3T for a GUI for MongoDB
Important bits
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 Macrosclass 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
endclass 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
endget “/stock/show” do |env|
#https://github.com/datanoise/mongo.cr
sis = Stockitem.all({“is_alias” => {“$eq” => false}})
name = “Stock :: Show & Edit”
mrender “stock”
endpost “/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 macroend #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.)
- note that I did not manage to set ({}) – will have to investigate this further
- this syntax is basically what MongoDB expects. see https://docs.mongodb.com/manual/reference/operator/query/
- 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
- https://github.com/sam0x17/mongo_orm/blob/893cd520cb90f7049d53b809ada30198deeed0f7/spec/fields_spec.cr
- https://github.com/datanoise/mongo.cr/commit/86c9e530c0ac980ed7b15e653746bc5b6f2527fc
- https://github.com/amberframework/granite/blob/master/docs/getting_started.md
- https://docs.mongodb.com/manual/reference/operator/query/
- https://github.com/datanoise/mongo.cr/issues/19
- https://github.com/datanoise/mongo.cr/blob/86c9e530c0ac980ed7b15e653746bc5b6f2527fc/src/mongo/collection.cr –> definition of save in mongo.cr
- https://github.com/sam0x17/mongo_orm/blob/015ae29a29ca8b80a98a531de891095bbd42d028/src/mongo_orm/persistence.cr –> definition of save in mongo_orm