Starting to add schema validation to API endpoints

master
Mark Moffat 2019-06-17 19:51:45 +09:30
parent de5a01c671
commit fc1580ddd6
16 changed files with 1122 additions and 187 deletions

42
app.js
View File

@ -13,6 +13,7 @@ const colors = require('colors');
const cron = require('node-cron');
const common = require('./lib/common');
const { runIndexing } = require('./lib/indexing');
const { addSchemas } = require('./lib/schema');
const { initDb } = require('./lib/db');
let handlebars = require('express-handlebars');
@ -78,7 +79,7 @@ app.set('view engine', 'hbs');
// helpers for the handlebar templating platform
handlebars = handlebars.create({
helpers: {
perRowClass: function(numProducts){
perRowClass: (numProducts) => {
if(parseInt(numProducts) === 1){
return'col-md-12 col-xl-12 col m12 xl12 product-item';
}
@ -94,7 +95,7 @@ handlebars = handlebars.create({
return'col-md-6 col-xl-6 col m6 xl6 product-item';
},
menuMatch: function(title, search){
menuMatch: (title, search) => {
if(!title || !search){
return'';
}
@ -103,22 +104,22 @@ handlebars = handlebars.create({
}
return'';
},
getTheme: function(view){
getTheme: (view) => {
return`themes/${config.theme}/${view}`;
},
formatAmount: function(amt){
formatAmount: (amt) => {
if(amt){
return numeral(amt).format('0.00');
}
return'0.00';
},
amountNoDecimal: function(amt){
amountNoDecimal: (amt) => {
if(amt){
return handlebars.helpers.formatAmount(amt).replace('.', '');
}
return handlebars.helpers.formatAmount(amt);
},
getStatusColor: function (status){
getStatusColor: (status) => {
switch(status){
case'Paid':
return'success';
@ -138,52 +139,58 @@ handlebars = handlebars.create({
return'danger';
}
},
checkProductOptions: function (opts){
checkProductOptions: (opts) => {
if(opts){
return'true';
}
return'false';
},
currencySymbol: function(value){
currencySymbol: (value) => {
if(typeof value === 'undefined' || value === ''){
return'$';
}
return value;
},
objectLength: function(obj){
objectLength: (obj) => {
if(obj){
return Object.keys(obj).length;
}
return 0;
},
checkedState: function (state){
stringify: (obj) => {
if(obj){
return JSON.stringify(obj);
}
return'';
},
checkedState: (state) => {
if(state === 'true' || state === true){
return'checked';
}
return'';
},
selectState: function (state, value){
selectState: (state, value) => {
if(state === value){
return'selected';
}
return'';
},
isNull: function (value, options){
isNull: (value, options) => {
if(typeof value === 'undefined' || value === ''){
return options.fn(this);
}
return options.inverse(this);
},
toLower: function (value){
toLower: (value) => {
if(value){
return value.toLowerCase();
}
return null;
},
formatDate: function (date, format){
formatDate: (date, format) => {
return moment(date).format(format);
},
ifCond: function (v1, operator, v2, options){
ifCond: (v1, operator, v2, options) => {
switch(operator){
case'==':
return(v1 === v2) ? options.fn(this) : options.inverse(this);
@ -207,7 +214,7 @@ handlebars = handlebars.create({
return options.inverse(this);
}
},
isAnAdmin: function (value, options){
isAnAdmin: (value, options) => {
if(value === 'true' || value === true){
return options.fn(this);
}
@ -355,6 +362,9 @@ initDb(config.databaseConnectionString, async (err, db) => {
config.trackStock = true;
}
// Process schemas
await addSchemas();
// We index when not in test env
if(process.env.NODE_ENV !== 'test'){
try{

View File

@ -40,6 +40,18 @@ const mongoSanitize = (param) => {
return param;
};
const safeParseInt = (param) => {
if(param){
try{
return parseInt(param);
}catch(ex){
return param;
}
}else{
return param;
}
};
const checkboxBool = (param) => {
if(param && param === 'on'){
return true;
@ -47,6 +59,13 @@ const checkboxBool = (param) => {
return false;
};
const convertBool = (value) => {
if(value === 'true' || value === true){
return true;
}
return false;
};
const showCartCloseBtn = (page) => {
let showCartCloseButton = true;
if(page === 'checkout' || page === 'pay'){
@ -512,7 +531,9 @@ module.exports = {
fileSizeLimit,
cleanHtml,
mongoSanitize,
safeParseInt,
checkboxBool,
convertBool,
showCartCloseBtn,
addSitemapProducts,
clearSessionValue,

23
lib/schema.js Normal file
View File

@ -0,0 +1,23 @@
const path = require('path');
const fs = require('fs');
const _ = require('lodash');
const Validator = require('jsonschema').Validator;
const v = new Validator();
const glob = require('glob-fs')();
const addSchemas = () => {
const schemaFiles = glob.readdirSync('./lib/**/*.json');
_.forEach(schemaFiles, (file) => {
const fileData = JSON.parse(fs.readFileSync(file, 'utf-8'));
v.addSchema(fileData, path.basename(schemaFiles[0], '.json'));
});
};
const validateJson = (schema, json) => {
return v.validate(json, schema);
};
module.exports = {
addSchemas,
validateJson
};

View File

@ -0,0 +1,40 @@
{
"id": "/editProduct",
"type": "object",
"properties": {
"productPermalink": {
"type": "string"
},
"productTitle": {
"type": "string"
},
"productPrice": {
"type": "number"
},
"productDescription": {
"type": "string"
},
"productPublished": {
"type": "boolean"
},
"productTags": {
"type": "string"
},
"productOptions": {
"type": ["object", "string", "null"]
},
"productComment": {
"type": "boolean"
},
"productStock": {
"type": ["number", "null"]
}
},
"required": [
"productPermalink",
"productTitle",
"productPrice",
"productDescription",
"productPublished"
]
}

View File

@ -0,0 +1,40 @@
{
"id": "/newProduct",
"type": "object",
"properties": {
"productPermalink": {
"type": "string"
},
"productTitle": {
"type": "string"
},
"productPrice": {
"type": "number"
},
"productDescription": {
"type": "string"
},
"productPublished": {
"type": "boolean"
},
"productTags": {
"type": "string"
},
"productOptions": {
"type": ["object", "string", "null"]
},
"productComment": {
"type": "boolean"
},
"productStock": {
"type": ["number", "null"]
}
},
"required": [
"productPermalink",
"productTitle",
"productPrice",
"productDescription",
"productPublished"
]
}

775
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -24,8 +24,10 @@
"express-handlebars": "^3.1.0",
"express-session": "^1.16.1",
"glob": "^7.1.4",
"glob-fs": "^0.1.7",
"helmet": "^3.18.0",
"html-entities": "^1.2.0",
"jsonschema": "^1.2.4",
"lodash": "^4.17.11",
"lunr": "^2.3.6",
"mime-db": "^1.40.0",

View File

@ -103,7 +103,7 @@ $(document).ready(function (){
$(this).addClass('table table-hover');
});
$('#frmProductTags').tokenfield();
$('#productTags').tokenfield();
$(document).on('click', '.dashboard_list', function(e){
window.document.location = $(this).attr('href');
@ -245,8 +245,8 @@ $(document).ready(function (){
var optOptions = $('#product_optOptions').val();
var optJson = {};
if($('#productOptJson').val() !== ''){
optJson = JSON.parse($('#productOptJson').val());
if($('#productOptions').val() !== ''){
optJson = JSON.parse($('#productOptions').val());
}
var html = '<li class="list-group-item">';
@ -271,7 +271,7 @@ $(document).ready(function (){
};
// write new json back to field
$('#productOptJson').val(JSON.stringify(optJson));
$('#productOptions').val(JSON.stringify(optJson));
// clear inputs
$('#product_optName').val('');
@ -593,14 +593,15 @@ $(document).ready(function (){
// Call to API to check if a permalink is available
$(document).on('click', '#validate_permalink', function(e){
if($('#frmProductPermalink').val() !== ''){
if($('#productPermalink').val() !== ''){
$.ajax({
method: 'POST',
url: '/admin/api/validate_permalink',
data: { 'permalink': $('#frmProductPermalink').val(), 'docId': $('#frmProductId').val() }
data: { 'permalink': $('#productPermalink').val(), 'docId': $('#frmProductId').val() }
})
.done(function(msg){
showNotification(msg, 'success');
console.log('msg', msg);
showNotification(msg.message, 'success');
})
.fail(function(msg){
showNotification(msg.responseJSON.message, 'danger');
@ -654,8 +655,8 @@ $(document).ready(function (){
// create a permalink from the product title if no permalink has already been set
$(document).on('click', '#frm_edit_product_save', function(e){
if($('#frmProductPermalink').val() === '' && $('#frmProductTitle').val() !== ''){
$('#frmProductPermalink').val(slugify($('#frmProductTitle').val()));
if($('#productPermalink').val() === '' && $('#productTitle').val() !== ''){
$('#productPermalink').val(slugify($('#productTitle').val()));
}
});

File diff suppressed because one or more lines are too long

View File

@ -147,10 +147,7 @@ router.get('/product/:id', (req, res) => {
if(err || result == null || result.productPublished === 'false'){
res.render('error', { title: 'Not found', message: 'Product not found', helpers: req.handlebars.helpers, config });
}else{
let productOptions = {};
if(result.productOptions){
productOptions = JSON.parse(result.productOptions);
}
let productOptions = result.productOptions;
// If JSON query param return json instead
if(req.query.json === 'true'){

View File

@ -1,6 +1,8 @@
const express = require('express');
const common = require('../lib/common');
const { restrict, checkAccess } = require('../lib/auth');
const { indexProducts } = require('../lib/indexing');
const { validateJson } = require('../lib/schema');
const colors = require('colors');
const rimraf = require('rimraf');
const fs = require('fs');
@ -78,76 +80,132 @@ router.get('/admin/product/new', restrict, checkAccess, (req, res) => {
router.post('/admin/product/insert', restrict, checkAccess, (req, res) => {
const db = req.app.db;
// Process supplied options
let productOptions = req.body.productOptions;
if(productOptions && typeof productOptions !== 'object'){
try{
productOptions = JSON.parse(req.body.productOptions);
}catch(ex){
console.log('Failure to parse options');
}
}
let doc = {
productPermalink: req.body.frmProductPermalink,
productTitle: common.cleanHtml(req.body.frmProductTitle),
productPrice: req.body.frmProductPrice,
productDescription: common.cleanHtml(req.body.frmProductDescription),
productPublished: req.body.frmProductPublished,
productTags: req.body.frmProductTags,
productOptions: common.cleanHtml(req.body.productOptJson),
productComment: common.checkboxBool(req.body.frmProductComment),
productPermalink: req.body.productPermalink,
productTitle: common.cleanHtml(req.body.productTitle),
productPrice: common.safeParseInt(req.body.productPrice),
productDescription: common.cleanHtml(req.body.productDescription),
productPublished: common.convertBool(req.body.productPublished),
productTags: req.body.productTags,
productOptions: productOptions || null,
productComment: common.checkboxBool(req.body.productComment),
productAddedDate: new Date(),
productStock: req.body.frmProductStock ? parseInt(req.body.frmProductStock) : null
productStock: common.safeParseInt(req.body.productStock) || null
};
db.products.count({ 'productPermalink': req.body.frmProductPermalink }, (err, product) => {
// Validate the body again schema
const schemaResult = validateJson('newProduct', doc);
if(!schemaResult.valid){
// If API request, return json
if(req.apiAuthenticated){
res.status(400).json(schemaResult.errors);
return;
}
console.log('schemaResult errors', schemaResult.errors);
req.session.message = 'Form invalid. Please check values and try again.';
req.session.messageType = 'danger';
// keep the current stuff
req.session.productTitle = req.body.productTitle;
req.session.productDescription = req.body.productDescription;
req.session.productPrice = req.body.productPrice;
req.session.productPermalink = req.body.productPermalink;
req.session.productOptions = productOptions;
req.session.productComment = common.checkboxBool(req.body.productComment);
req.session.productTags = req.body.productTags;
req.session.productStock = req.body.productStock ? parseInt(req.body.productStock) : null;
// redirect to insert
res.redirect('/admin/product/new');
return;
}
db.products.count({ 'productPermalink': req.body.productPermalink }, (err, product) => {
if(err){
console.info(err.stack);
}
if(product > 0 && req.body.frmProductPermalink !== ''){
if(product > 0 && req.body.productPermalink !== ''){
// permalink exits
req.session.message = 'Permalink already exists. Pick a new one.';
req.session.messageType = 'danger';
// keep the current stuff
req.session.productTitle = req.body.frmProductTitle;
req.session.productDescription = req.body.frmProductDescription;
req.session.productPrice = req.body.frmProductPrice;
req.session.productPermalink = req.body.frmProductPermalink;
req.session.productPermalink = req.body.productOptJson;
req.session.productComment = common.checkboxBool(req.body.frmProductComment);
req.session.productTags = req.body.frmProductTags;
req.session.productStock = req.body.frmProductStock ? parseInt(req.body.frmProductStock) : null;
req.session.productTitle = req.body.productTitle;
req.session.productDescription = req.body.productDescription;
req.session.productPrice = req.body.productPrice;
req.session.productPermalink = req.body.productPermalink;
req.session.productOptions = productOptions;
req.session.productComment = common.checkboxBool(req.body.productComment);
req.session.productTags = req.body.productTags;
req.session.productStock = req.body.productStock ? parseInt(req.body.productStock) : null;
// If API request, return json
if(req.apiAuthenticated){
res.status(400).json({ error: 'Permalink already exists. Pick a new one.' });
return;
}
// redirect to insert
res.redirect('/admin/insert');
}else{
db.products.insert(doc, (err, newDoc) => {
if(err){
console.log(colors.red('Error inserting document: ' + err));
// keep the current stuff
req.session.productTitle = req.body.frmProductTitle;
req.session.productDescription = req.body.frmProductDescription;
req.session.productPrice = req.body.frmProductPrice;
req.session.productPermalink = req.body.frmProductPermalink;
req.session.productPermalink = req.body.productOptJson;
req.session.productComment = common.checkboxBool(req.body.frmProductComment);
req.session.productTags = req.body.frmProductTags;
req.session.productStock = req.body.frmProductStock ? parseInt(req.body.frmProductStock) : null;
req.session.message = 'Error: Inserting product';
req.session.messageType = 'danger';
// redirect to insert
res.redirect('/admin/product/new');
}else{
// get the new ID
let newId = newDoc.insertedIds[0];
// add to lunr index
common.indexProducts(req.app)
.then(() => {
req.session.message = 'New product successfully created';
req.session.messageType = 'success';
// redirect to new doc
res.redirect('/admin/product/edit/' + newId);
});
}
});
res.redirect('/admin/product/new');
return;
}
db.products.insert(doc, (err, newDoc) => {
if(err){
console.log(colors.red('Error inserting document: ' + err));
// keep the current stuff
req.session.productTitle = req.body.productTitle;
req.session.productDescription = req.body.productDescription;
req.session.productPrice = req.body.productPrice;
req.session.productPermalink = req.body.productPermalink;
req.session.productOptions = productOptions;
req.session.productComment = common.checkboxBool(req.body.productComment);
req.session.productTags = req.body.productTags;
req.session.productStock = req.body.productStock ? parseInt(req.body.productStock) : null;
req.session.message = 'Error: Inserting product';
req.session.messageType = 'danger';
// If API request, return json
if(req.apiAuthenticated){
res.status(400).json({ error: `Error inserting document: ${err}` });
return;
}
// redirect to insert
res.redirect('/admin/product/new');
return;
}
// get the new ID
let newId = newDoc.insertedIds[0];
// add to lunr index
indexProducts(req.app)
.then(() => {
req.session.message = 'New product successfully created';
req.session.messageType = 'success';
// If API request, return json
if(req.apiAuthenticated){
res.status(200).json({ error: 'New product successfully created' });
return;
}
// redirect to new doc
res.redirect('/admin/product/edit/' + newId);
});
});
});
});
@ -162,7 +220,7 @@ router.get('/admin/product/edit/:id', restrict, checkAccess, (req, res) => {
}
let options = {};
if(result.productOptions){
options = JSON.parse(result.productOptions);
options = result.productOptions;
}
res.render('product_edit', {
@ -194,7 +252,7 @@ router.post('/admin/product/update', restrict, checkAccess, (req, res) => {
res.redirect('/admin/product/edit/' + req.body.frmProductId);
return;
}
db.products.count({ 'productPermalink': req.body.frmProductPermalink, _id: { $ne: common.getId(product._id) } }, (err, count) => {
db.products.count({ 'productPermalink': req.body.productPermalink, _id: { $ne: common.getId(product._id) } }, (err, count) => {
if(err){
console.info(err.stack);
req.session.message = 'Failed updating product.';
@ -203,37 +261,75 @@ router.post('/admin/product/update', restrict, checkAccess, (req, res) => {
return;
}
if(count > 0 && req.body.frmProductPermalink !== ''){
if(count > 0 && req.body.productPermalink !== ''){
// permalink exits
req.session.message = 'Permalink already exists. Pick a new one.';
req.session.messageType = 'danger';
// keep the current stuff
req.session.productTitle = req.body.frmProductTitle;
req.session.productDescription = req.body.frmProductDescription;
req.session.productPrice = req.body.frmProductPrice;
req.session.productPermalink = req.body.frmProductPermalink;
req.session.productTags = req.body.frmProductTags;
req.session.productOptions = req.body.productOptJson;
req.session.productComment = common.checkboxBool(req.body.frmProductComment);
req.session.productStock = req.body.frmProductStock ? req.body.frmProductStock : null;
req.session.productTitle = req.body.productTitle;
req.session.productDescription = req.body.productDescription;
req.session.productPrice = req.body.productPrice;
req.session.productPermalink = req.body.productPermalink;
req.session.productTags = req.body.productTags;
req.session.productOptions = req.body.productOptions;
req.session.productComment = common.checkboxBool(req.body.productComment);
req.session.productStock = req.body.productStock ? req.body.productStock : null;
// redirect to insert
res.redirect('/admin/product/edit/' + req.body.frmProductId);
}else{
}else{
common.getImages(req.body.frmProductId, req, res, (images) => {
// Process supplied options
let productOptions = req.body.productOptions;
if(productOptions && typeof productOptions !== 'object'){
try{
productOptions = JSON.parse(req.body.productOptions);
}catch(ex){
console.log('Failure to parse options');
}
}
let productDoc = {
productTitle: common.cleanHtml(req.body.frmProductTitle),
productDescription: common.cleanHtml(req.body.frmProductDescription),
productPublished: req.body.frmProductPublished,
productPrice: req.body.frmProductPrice,
productPermalink: req.body.frmProductPermalink,
productTags: common.cleanHtml(req.body.frmProductTags),
productOptions: common.cleanHtml(req.body.productOptJson),
productComment: common.checkboxBool(req.body.frmProductComment),
productStock: req.body.frmProductStock ? parseInt(req.body.frmProductStock) : null
productPermalink: req.body.productPermalink,
productTitle: common.cleanHtml(req.body.productTitle),
productPrice: common.safeParseInt(req.body.productPrice),
productDescription: common.cleanHtml(req.body.productDescription),
productPublished: common.convertBool(req.body.productPublished),
productTags: req.body.productTags,
productOptions: productOptions || null,
productComment: common.checkboxBool(req.body.productComment),
productStock: common.safeParseInt(req.body.productStock) || null
};
// Validate the body again schema
const schemaResult = validateJson('editProduct', productDoc);
if(!schemaResult.valid){
// If API request, return json
if(req.apiAuthenticated){
res.status(400).json(schemaResult.errors);
return;
}
console.log('schemaResult errors', schemaResult.errors);
req.session.message = 'Form invalid. Please check values and try again.';
req.session.messageType = 'danger';
// keep the current stuff
req.session.productTitle = req.body.productTitle;
req.session.productDescription = req.body.productDescription;
req.session.productPrice = req.body.productPrice;
req.session.productPermalink = req.body.productPermalink;
req.session.productOptions = productOptions;
req.session.productComment = common.checkboxBool(req.body.productComment);
req.session.productTags = req.body.productTags;
req.session.productStock = req.body.productStock ? parseInt(req.body.productStock) : null;
// redirect to insert
res.redirect('/admin/product/edit/' + req.body.frmProductId);
return;
}
// if no featured image
if(!product.productImage){
if(images.length > 0){
@ -253,7 +349,7 @@ router.post('/admin/product/update', restrict, checkAccess, (req, res) => {
res.redirect('/admin/product/edit/' + req.body.frmProductId);
}else{
// Update the index
common.indexProducts(req.app)
indexProducts(req.app)
.then(() => {
req.session.message = 'Successfully saved';
req.session.messageType = 'success';
@ -283,7 +379,7 @@ router.get('/admin/product/delete/:id', restrict, checkAccess, (req, res) => {
}
// remove the index
common.indexProducts(req.app)
indexProducts(req.app)
.then(() => {
// redirect home
req.session.message = 'Product successfully deleted';

View File

@ -10,24 +10,24 @@
<h2>Edit product</h2>
</div>
<div class="form-group">
<label for="frmProductTitle" class="col-sm-2 control-label">Product title *</label>
<label for="productTitle" class="col-sm-2 control-label">Product title *</label>
<div class="col-sm-10">
<input type="text" name="frmProductTitle" class="form-control" minlength="5" maxlength="200" value="{{result.productTitle}}" required/>
<input type="text" name="productTitle" class="form-control" minlength="5" maxlength="200" value="{{result.productTitle}}" required/>
</div>
</div>
<div class="form-group">
<label for="frmProductPrice" class="col-sm-2 control-label">Product price *</label>
<label for="productPrice" class="col-sm-2 control-label">Product price *</label>
<div class="col-sm-6">
<div class="input-group">
<span class="input-group-addon">{{currencySymbol config.currencySymbol}}</span>
<input type="number" name="frmProductPrice" class="form-control" value="{{result.productPrice}}" step="any" required/>
<input type="number" name="productPrice" class="form-control" value="{{result.productPrice}}" step="any" required/>
</div>
</div>
</div>
<div class="form-group">
<label for="frmProductPublished" class="col-sm-2 control-label">Status</label>
<label for="productPublished" class="col-sm-2 control-label">Status</label>
<div class="col-sm-6">
<select class="form-control" id="frmProductPublished" name="frmProductPublished">
<select class="form-control" id="productPublished" name="productPublished">
<option value="true" {{selectState result.productPublished "true"}}>Published</option>
<option value="false" {{selectState result.productPublished "false"}}>Draft</option>
</select>
@ -35,23 +35,23 @@
</div>
{{#if config.trackStock}}
<div class="form-group">
<label for="frmProductStock" class="col-sm-2 control-label">Stock level</label>
<label for="productStock" class="col-sm-2 control-label">Stock level</label>
<div class="col-sm-6">
<input type="number" name="frmProductStock" class="form-control" value="{{result.productStock}}" step="any" />
<input type="number" name="productStock" class="form-control" value="{{result.productStock}}" step="any" />
</div>
</div>
{{/if}}
<div class="form-group">
<label for="editor" class="col-sm-2 control-label">Product description *</label>
<div class="col-sm-10">
<textarea id="editor" minlength="5" rows="10" id="frmProductDescription" name="frmProductDescription" class="form-control" required>{{result.productDescription}}</textarea>
<textarea id="editor" minlength="5" rows="10" id="productDescription" name="productDescription" class="form-control" required>{{result.productDescription}}</textarea>
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label">Permalink</label>
<div class="col-sm-10">
<div class="input-group">
<input type="text" class="form-control" name="frmProductPermalink" id="frmProductPermalink" placeholder="Permalink for the article" value={{result.productPermalink}}>
<input type="text" class="form-control" name="productPermalink" id="productPermalink" placeholder="Permalink for the article" value={{result.productPermalink}}>
<span class="input-group-btn">
<button class="btn btn-success" id="validate_permalink" type="button">Validate</button>
</span>
@ -60,7 +60,7 @@
</div>
</div>
<div class="form-group">
<input type="hidden" id="productOptJson" name="productOptJson" value="{{result.productOptions}}" />
<input type="hidden" id="productOptions" name="productOptions" value="{{stringify result.productOptions}}" />
<label for="editor" class="col-sm-2 control-label">Product options</label>
<div class="col-lg-10">
<ul class="list-group" id="product_opt_wrapper">
@ -108,21 +108,21 @@
</div>
</div>
<div class="form-group">
<label for="frmProductComment" class="col-sm-2 control-label">Allow comment</label>
<label for="productComment" class="col-sm-2 control-label">Allow comment</label>
<div class="col-sm-10">
<div class="checkbox">
<label>
<input class="frmProductComment" type="checkbox" {{checkedState result.productComment}} id="frmProductComment"
name="frmProductComment">
<input class="productComment" type="checkbox" {{checkedState result.productComment}} id="productComment"
name="productComment">
</label>
</div>
<p class="help-block">Allow free form comments when adding products to cart</p>
</div>
</div>
<div class="form-group">
<label for="frmProductTags" class="col-sm-2 control-label">Product tag words</label>
<label for="productTags" class="col-sm-2 control-label">Product tag words</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="frmProductTags" name="frmProductTags" value="{{result.productTags}}">
<input type="text" class="form-control" id="productTags" name="productTags" value="{{result.productTags}}">
<p class="help-block">Tag words used to indexed products, making them easier to find and filter.</p>
</div>
</div>

View File

@ -9,24 +9,24 @@
<h2>New product</h2>
</div>
<div class="form-group">
<label for="frmProductTitle" class="col-sm-2 control-label">Product title *</label>
<label for="productTitle" class="col-sm-2 control-label">Product title *</label>
<div class="col-sm-10">
<input type="text" id="frmProductTitle" name="frmProductTitle" class="form-control" minlength="5" maxlength="200" value="{{productTitle}}" required/>
<input type="text" id="productTitle" name="productTitle" class="form-control" minlength="5" maxlength="200" value="{{productTitle}}" required/>
</div>
</div>
<div class="form-group">
<label for="frmProductPrice" class="col-sm-2 control-label">Product price *</label>
<label for="productPrice" class="col-sm-2 control-label">Product price *</label>
<div class="col-sm-6">
<div class="input-group">
<span class="input-group-addon">$</span>
<input type="number" name="frmProductPrice" class="form-control" step="any" value="{{productPrice}}" required/>
<input type="number" name="productPrice" class="form-control" step="any" value="{{productPrice}}" required/>
</div>
</div>
</div>
<div class="form-group">
<label for="frmProductPublished" class="col-sm-2 control-label">Status</label>
<label for="productPublished" class="col-sm-2 control-label">Status</label>
<div class="col-sm-6">
<select class="form-control" id="frmProductPublished" name="frmProductPublished">
<select class="form-control" id="productPublished" name="productPublished">
<option value="true" selected>Published</option>
<option value="false">Draft</option>
</select>
@ -34,23 +34,23 @@
</div>
{{#if config.trackStock}}
<div class="form-group">
<label for="frmProductStock" class="col-sm-2 control-label">Stock level</label>
<label for="productStock" class="col-sm-2 control-label">Stock level</label>
<div class="col-sm-6">
<input type="number" name="frmProductStock" class="form-control" value="{{productStock}}" step="any" />
<input type="number" name="productStock" class="form-control" value="{{productStock}}" step="any" />
</div>
</div>
{{/if}}
<div class="form-group" id="editor-wrapper">
<label for="editor" class="col-sm-2 control-label">Product description *</label>
<div class="col-sm-10">
<textarea id="editor" minlength="5" rows="10" name="frmProductDescription" class="form-control" required>{{productDescription}}</textarea>
<textarea id="editor" minlength="5" rows="10" name="productDescription" class="form-control" required>{{productDescription}}</textarea>
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label">Permalink</label>
<div class="col-sm-10">
<div class="input-group">
<input type="text" class="form-control" name="frmProductPermalink" id="frmProductPermalink" placeholder="Permalink for the article" value={{productPermalink}}>
<input type="text" class="form-control" name="productPermalink" id="productPermalink" placeholder="Permalink for the article" value={{productPermalink}}>
<span class="input-group-btn">
<button class="btn btn-success" id="validate_permalink" type="button">Validate</button>
</span>
@ -59,7 +59,7 @@
</div>
</div>
<div class="form-group">
<input type="hidden" id="productOptJson" name="productOptJson" value="{{result.productOptions}}" />
<input type="hidden" id="productOptions" name="productOptions" value="{{result.productOptions}}" />
<label for="editor" class="col-sm-2 control-label">Product options</label>
<div class="col-lg-10">
<ul class="list-group" id="product_opt_wrapper">
@ -107,21 +107,21 @@
</div>
</div>
<div class="form-group">
<label for="frmProductComment" class="col-sm-2 control-label">Allow comment</label>
<label for="productComment" class="col-sm-2 control-label">Allow comment</label>
<div class="col-sm-10">
<div class="checkbox">
<label>
<input class="frmProductComment" type="checkbox" {{checkedState result.productComment}} id="frmProductComment"
name="frmProductComment">
<input class="productComment" type="checkbox" {{checkedState result.productComment}} id="productComment"
name="productComment">
</label>
</div>
<p class="help-block">Allow free form comments when adding products to cart</p>
</div>
</div>
<div class="form-group">
<label for="frmProductTags" class="col-sm-2 control-label">Product tag words</label>
<label for="productTags" class="col-sm-2 control-label">Product tag words</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="frmProductTags" name="frmProductTags">
<input type="text" class="form-control" id="productTags" name="productTags">
<p class="help-block">Tag words used to indexed products, making them easier to find and filter.</p>
</div>
</div>

View File

@ -12,17 +12,17 @@
{{#ifCond this.optType '==' "select"}}
<strong>{{this.optName}}</strong>
<select name="opt-{{this.optName}}" class="form-control product-opt">
{{#each this.optOptions}}
{{#each ../this.optOptions}}
<option value="{{this}}">{{this}}</option>
{{/each}}
</select>
{{/ifCond}}
{{#ifCond this.optType '==' "radio"}}
{{#each this.optOptions}}
{{#each ../this.optOptions}}
<strong>{{this.optName}}</strong>
<div class="radio">
<label>
<input type="radio" class="product-opt" name="opt-{{../this.optName}}" value="{{this}}">
<input type="radio" class="product-opt" name="opt-{{../../this.optName}}" value="{{this}}">
{{this}}
</label>
</div>
@ -31,7 +31,7 @@
{{#ifCond this.optType '==' "checkbox"}}
<div class="checkbox">
<label>
<input type="checkbox" class="product-opt" name="opt-{{../this.optName}}" value="{{this.optName}}"><strong>{{this.optName}}</strong>
<input type="checkbox" class="product-opt" name="opt-{{../this.optName}}" value="{{../this.optName}}"><strong>{{../this.optName}}</strong>
</label>
</div>
{{/ifCond}}

View File

@ -12,17 +12,17 @@
{{#ifCond this.optType '==' "select"}}
<strong>{{this.optName}}</strong>
<select name="opt-{{this.optName}}" class="form-control product-opt">
{{#each this.optOptions}}
{{#each ../this.optOptions}}
<option value="{{this}}">{{this}}</option>
{{/each}}
</select>
{{/ifCond}}
{{#ifCond this.optType '==' "radio"}}
{{#each this.optOptions}}
{{#each ../this.optOptions}}
<strong>{{this.optName}}</strong>
<div class="radio">
<label>
<input type="radio" class="product-opt" name="opt-{{../this.optName}}" value="{{this}}">
<input type="radio" class="product-opt" name="opt-{{../../this.optName}}" value="{{this}}">
{{this}}
</label>
</div>
@ -31,7 +31,7 @@
{{#ifCond this.optType '==' "checkbox"}}
<div class="checkbox">
<label>
<input type="checkbox" class="product-opt" name="opt-{{../this.optName}}" value="{{this.optName}}"><strong>{{this.optName}}</strong>
<input type="checkbox" class="product-opt" name="opt-{{../this.optName}}" value="{{../this.optName}}"><strong>{{../this.optName}}</strong>
</label>
</div>
{{/ifCond}}

View File

@ -12,17 +12,17 @@
{{#ifCond this.optType '==' "select"}}
<strong>{{this.optName}}</strong>
<select name="opt-{{this.optName}}" class="form-control product-opt">
{{#each this.optOptions}}
{{#each ../this.optOptions}}
<option value="{{this}}">{{this}}</option>
{{/each}}
</select>
{{/ifCond}}
{{#ifCond this.optType '==' "radio"}}
{{#each this.optOptions}}
{{#each ../this.optOptions}}
<strong>{{this.optName}}</strong>
<div class="radio">
<label>
<input type="radio" class="product-opt" name="opt-{{../this.optName}}" value="{{this}}">
<input type="radio" class="product-opt" name="opt-{{../../this.optName}}" value="{{this}}">
{{this}}
</label>
</div>
@ -31,7 +31,7 @@
{{#ifCond this.optType '==' "checkbox"}}
<div class="checkbox">
<label>
<input type="checkbox" class="product-opt" name="opt-{{../this.optName}}" value="{{this.optName}}"><strong>{{this.optName}}</strong>
<input type="checkbox" class="product-opt" name="opt-{{../this.optName}}" value="{{../this.optName}}"><strong>{{../this.optName}}</strong>
</label>
</div>
{{/ifCond}}