Solution
This is an example implementation of the F-store. It can also be found in its entirety on GitHub here: Rails, app.
Rails
The time at which the file was created and its class name should be in the filename.
# web/app/db/migrate/YYYYMMDDHHMMSS_create_store_products.rb class CreateStoreProducts < ActiveRecord::Migration[5.0] def change create_table :store_products do |t| t.string :name, null: false t.integer :price, null: false, default: 0 t.text :image_url t.boolean :in_stock, null: false t.timestamps null: false end end end
In this example implementation we have set the fields
name,priceandin_stockas mandatory. This was done by specifingnull: false. We have also set the defaultpriceto zero usingdefault: 0. Thus one can create a product without specifying a price, since the default value then will be set, resulting in the field being filled. We also always want to havetimestampsto be able to see when each product was created.For Rails to recognize the model it is important that the class and filename is in singular.
# web/app/models/store_product class StoreProduct < ApplicationRecord validates :name, presence: true validates :price, numericality: { greater_than_or_equal_to: 0 } scope :in_stock, -> { where(in_stock: true) } def to_s name end end
Here we require that each
StoreProductmust have anameby settingpresence: true. In themodelone can also specifyscopes. In this case, callingStoreProducts.in_stockwill return all the products that have the attributein_stockset totrue.Creating and saving a
StoreProductcan e.g. by done by typingStoreProduct.create!(name: 'Product 1', price: 100, in_stock: true)
into the Rails console.
Here, the admin path to the products will become
/admin/store_products. Theexceptstatement can be used if some methods are not implemented in thecontroller, which in this case is theshowaction.# web/app/config/routes.rb namespace :admin do resources :store_products, except: [:show], path: :produkter end
Here follows the entire
controllerfile:# web/app/controllers/admin/store_products_controller.rb class Admin::StoreProductsController < Admin::BaseController load_permissions_and_authorize_resource def new @store_product = StoreProduct.new end def index @store_products = initialize_grid(StoreProduct.all, order: :name) end def edit @store_product = StoreProduct.find(params[:id]) end def create @store_product = StoreProduct.new(store_product_params) if @store_product.save redirect_to admin_store_products_path, notice: alert_create(StoreProduct) else redirect_to new_admin_store_product_path(@store_product), notice: alert_danger('Kunde inte skapa produkt') end end def update @store_product = StoreProduct.find(params[:id]) if @store_product.update(store_product_params) redirect_to admin_store_products_path, notice: alert_update(StoreProduct) else redirect_to edit_admin_store_product_path(@store_product), notice: alert_danger('Kunde inte uppdatera produkt') end end def destroy @store_product = StoreProduct.find(params[:id]) if @store_product.destroy redirect_to admin_store_products_path, notice: alert_destroy(StoreProduct) else redirect_to edit_admin_store_product_path, notice: alert_danger('Kunde inte förinta produkt') end end private def store_product_params params.require(:store_product).permit(:name, :price, :image_url, :in_stock) end end
Here follows the code for all the
views:<% # web/app/views/admin/store_products/index.html.erb %> <div class="headline"> <h1><%= title('Produkter') %></h1> </div> <div class="col-md-2 col-sm-12"> <%= link_to('Ny produkt', new_admin_store_product_path, class: 'btn primary') %> </div> <div class="col-md-10 col-sm-12"> <%= grid(@store_products) do |g| g.column(name: 'Namn', attribute: 'name') do |product| link_to(product, edit_admin_store_product_path(product)) end g.column(name: 'Pris', attribute: 'price', filter: false) g.column(name: 'I lager', attribute: 'in_stock', filter: false) do |product| if product.in_stock? then t('global.yes') else t('global.no') end end end -%> </div>
Two comments regarding the code above. Firstly, the
filter: falseargument will remove the possibility to search that column, i.e. that one cannot search for all prodcuts with e.g. the price37. Secondly, for thein_stockcolumn we replace the value witht('global.yes')ort('global.no')depending on if the product is in stock or not. Rails fetches these values from a translation file (web/config/locales/views/global.sv.ymlif the website is set to display in Swedish) where a (Swedish) translation ofYesandNoexists.<% # web/app/views/admin/store_products/_form.html.erb %> <%= simple_form_for([:admin, store_product]) do |f| %> <%= f.input :name %> <%= f.input :price %> <%= f.input :in_stock %> <%= f.input :image_url %> <%= f.button :submit %> <% end %>
<% # web/app/views/admin/store_products/new.html.erb %> <div class="col-md-10 col-md-offset-1 col-sm-12 reg-page"> <div class="headline"> <h3><%= title('Ny produkt') %></h3> </div> <%= render('form', store_product: @store_product) %> <hr> <%= link_to('Alla produkter', admin_store_products_path, class: 'btn secondary') %> </div>
<% # web/app/views/admin/store_products/edit.html.erb %> <div class="col-md-10 col-md-offset-1 col-sm-12 reg-page"> <div class="headline"> <h1><%= 'Redigera produkt' %></h1> </div> <%= render('form', store_product: @store_product) %> <hr> <%= link_to('Förinta', admin_store_product_path(@store_product), method: :delete, data: {confirm: 'Är du säker på att du vill förinta produkten?'}, class: 'btn danger pull-right') %> <%= link_to('Alla produkter', admin_store_products_path, class: 'btn secondary') %> </div>
Here, all the fields are included in the
Indexserializer.# web/app/serializers/api/store_product_serializer.rb class Api::StoreProductSerializer < ActiveModel::Serializer class Api::StoreProductSerializer::Index < ActiveModel::Serializer attributes(:id, :name, :price, :in_stock, :image_url) end end
The
API controllerformats the data of each product with the implementedStoreProductSerializerand outputs everything as a JSON object.# web/app/controllers/api/store_products_controller.rb class Api::StoreProductsController < Api::BaseController load_permissions_and_authorize_resource def index @store_products = StoreProduct.all render json: @store_products, each_serializer: Api::StoreProductSerializer::Index end end
The ability to see all products can be done by writing:
# web/app/models/ability.rb can :index, StoreProduct
Here, the API path will become
/api/store_products. Withonlywe specify that the only method we have implemented in theAPI controllerisindex.# web/app/config/routes.rb resources :store_products, only: :index
App
We create the files
app/www/store.html,app/www/scss/partials/_store.scssandapp/www/js/store.js. The JS and SCSS files are loaded by adding the respective lines.<!-- app/www/index.html --> <script type="text/javascript" src="js/store.js"></script>
// app/www/scss/index.scss @import 'partials/store';
The route is added by specifing a
nameandpathto the new page, as well as anurlto the HTML file it should render.// app/www/js/index.js var alternativesView = app.views.create('#view-alternatives', { routesAdd: [ // { // ... Other routes // }, { name: 'store', path: '/store/', url: './store.html', }, // { // ... Even more routes // } ] });
Here it is important that the
data-nameis thenamewe defined in the routes, i.e.store.<!-- app/www/store.html --> <div data-name="store" class="page no-toolbar"> <div class="navbar"> <div class="navbar-inner sliding"> <div class="left"> <a href="#" class="back link"> <i class="icon icon-back"></i> <span class="ios-only">Tillbaka</span> </a> </div> <div class="title">F-shoppen</div> </div> </div> <div class="page-content store-content"> <div class="infinite-scroll-preloader"> <div class="preloader"></div> </div> </div> </div>
Here the navigation is added to the top of the alternatives view list.
<!-- app/www/index-html --> <div id="view-alternatives" class="view tab"> <div data-name="alternatives" class="page"> <div class="navbar android-hide"> <div class="navbar-inner sliding"> <div class="title">Alternativ</div> </div> </div> <div class="page-content settings-content"> <div class="list"> <ul> <li> <a href="/store/" class="item-link"> <div class="item-content"> <div class="item-inner"> <div class="item-title">F-shoppen</div> </div> </div> </a> </li> <!-- ... Another list item //--> </ul> </div> </div> </div> </div>
We can catch the
page:initevent when it’s called on the page wheredata-name="store"by doing the following:// app/www/js/store.js $$(document).on('page:init', '.page[data-name="store"]', function () { console.log('Spodermon iz kewl'); });
Our JS file can now look like:
// app/www/js/store.js $$(document).on('page:init', '.page[data-name="store"]', function () { let storeProductAPIEndpointURL = API + '/store_products'; $.getJSON(storeProductAPIEndpointURL) .done(function(resp) { initStore(resp); }) .fail(function(resp) { console.log(resp.statusText); }); function initStore(resp) { console.log(resp); } });
Here we have used the global variable
APIto define our URL. The value ofAPIis defined inapp/www/js/index.js.Here we create a simple template with the
idstoreTemplate<!-- app/www/index.html --> <script type="text/template7" id="storeTemplate"> Welcome to the F-store! </script>
and can test if it works by extending our JS file to:
// app/www/js/store.js $$(document).on('page:init', '.page[data-name="store"]', function () { let storeProductAPIEndpointURL = API + '/store_products'; $.getJSON(storeProductAPIEndpointURL) .done(function(resp) { initStore(resp); }) .fail(function(resp) { console.log(resp.statusText); }); function initStore(resp) { let templateHTML = app.templates.storeTemplate(); let storeContainer = $('.store-content'); storeContainer.html(templateHTML); } });
Here we first get the HTML code of template and then put it into
<div class="page-content store-content"></div>inapp/www/store.html.An example template:
<!-- app/www/index.html --> <script type="text/template7" id="storeTemplate"> {{#each products}} <div class="card"> <div class="card-header" style="background-image: url({{image_url}})"></div> <div class="card-content card-content-padding"> <div class="product-name">{{name}}</div> Pris: {{price}} kr <button data-id="{{id}}" class="button button-fill buy-product">Köp</button> </div> </div> {{/each}} </script>
We can loop over the products and set the price to be in Swedish Kronor as:
// app/www/js/store.js function initStore(resp) { let products = resp.store_products; products.forEach(function(product) { product.price /= 100; if (product.image_url === "") { product.image_url = "img/missing_thumb.png"; } }); let templateHTML = app.templates.storeTemplate({products: products}); let storeContainer = $('.store-content'); storeContainer.html(templateHTML); }
Here we also set the image to be our standard missing thumbnail image if the product does not have an
image_url.Here we catch the
clickevent, get the productidfrom the button and call thebuyProductfunction. The$(this)is needed to get the correctdata-id.// app/www/js/store.js function initStore(resp) { let products = resp.store_products; products.forEach(function(product) { product.price /= 100; if (product.image_url === "") { product.image_url = "img/missing_thumb.png"; } }); let templateHTML = app.templates.storeTemplate({products: products}); let storeContainer = $('.store-content'); storeContainer.html(templateHTML); $('.buy-product').on('click', function() { buyBtn = $(this); productId = buyBtn.attr('data-id'); buyProduct(productId); }); } function buyProduct(id) { $.ajax({ url: API + '/store_orders', type: 'POST', dataType: 'json', data: { "item": { "id": id, "quantity": 1 } }, success: function(resp) { app.dialog.alert(resp.success, 'Varan är köpt'); }, error: function(resp) { app.dialog.alert(resp.responseJSON.error); } }); }
SCSS code and the complete JS file:
// app/www/scss/partials/_store.scss .store-content { .card:nth-child(-n+2) { margin-top: 16px; } .card { width: calc(50% - 18px); float: left; box-shadow: none; margin-left: 8px } .card-header { background-size: cover; background-repeat: no-repeat; background-position: center; background-color: #f8f8f8; height: 37vh; } .card-content { text-align: center; } .product-name { font-size: 19px; font-weight: bold; } .buy-product { background-color: $fsek-orange; margin-top: 10px; } }
// app/www/js/store.js $$(document).on('page:init', '.page[data-name="store"]', function () { let storeProductAPIEndpointURL = API + '/store_products'; $.getJSON(storeProductAPIEndpointURL) .done(function(resp) { initStore(resp); }) .fail(function(resp) { console.log(resp.statusText); }); function initStore(resp) { let products = resp.store_products; products.forEach(function(product) { product.price /= 100; if (product.image_url === "") { product.image_url = "img/missing_thumb.png"; } }); let templateHTML = app.templates.storeTemplate({products: products}); let storeContainer = $('.store-content'); storeContainer.html(templateHTML); $('.buy-product').on('click', function() { buyBtn = $(this); productId = buyBtn.attr('data-id'); buyProduct(productId); }); } function buyProduct(id) { $.ajax({ url: API + '/store_orders', type: 'POST', dataType: 'json', data: { "item": { "id": id, "quantity": 1 } }, success: function(resp) { app.dialog.alert(resp.success, 'Varan är köpt'); }, error: function(resp) { app.dialog.alert(resp.responseJSON.error); } }); } });