├── client ├── index.js └── templates │ └── dashboard │ ├── index.js │ ├── dashboard.js │ ├── dashboard.html │ ├── customFields.html │ └── customFields.js ├── tests └── jasmine │ └── server │ ├── integration │ └── methods │ │ └── productImporter.js │ └── unit │ └── productImporter.js ├── server ├── index.js ├── api │ ├── index.js │ └── productImporter.js └── methods │ └── productImporter.js ├── register.js ├── README.md └── .eslintrc /client/index.js: -------------------------------------------------------------------------------- 1 | import './templates/dashboard'; 2 | -------------------------------------------------------------------------------- /tests/jasmine/server/integration/methods/productImporter.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | import './methods/productImporter'; 2 | 3 | export * from './api'; 4 | -------------------------------------------------------------------------------- /client/templates/dashboard/index.js: -------------------------------------------------------------------------------- 1 | import './customFields'; 2 | import './dashboard'; 3 | -------------------------------------------------------------------------------- /server/api/index.js: -------------------------------------------------------------------------------- 1 | import './productImporter'; 2 | export * from './productImporter'; 3 | -------------------------------------------------------------------------------- /tests/jasmine/server/unit/productImporter.js: -------------------------------------------------------------------------------- 1 | beforeAll(function () { 2 | VelocityHelpers.exportGlobals(); 3 | }); 4 | 5 | describe('getoutfitted:reaction-product-importer product-importer object', function () { 6 | // describe('ProductImporter.existingProduct', function () { 7 | // beforeEach(function () { 8 | // return ReactionCore.Collections.Products.remove({}); 9 | // }); 10 | // it('should return the correct type of product', function () { 11 | // let product = Factory.create('product'); 12 | // let variant = Factory.create('variant'); 13 | // console.log(ProductImporter) 14 | // // let result = ProductImporter.existingProduct(product, 'simple'); 15 | // // expect(result.title).toBe(product.title); 16 | 17 | 18 | // }); 19 | // }); 20 | }); 21 | -------------------------------------------------------------------------------- /register.js: -------------------------------------------------------------------------------- 1 | import { Reaction } from '/server/api'; 2 | 3 | Reaction.registerPackage({ 4 | label: 'Import Products from CSV', 5 | name: 'reaction-product-importer', 6 | icon: 'fa fa-cloud-upload', 7 | autoEnable: false, 8 | settings: { 9 | customFields: { 10 | topProduct: [], 11 | midVariant: [], 12 | variant: [] 13 | } 14 | }, 15 | registry: [ 16 | { 17 | provides: 'dashboard', 18 | label: 'Product Importer', 19 | description: 'Import Products into Reaction from CSV', 20 | route: '/dashboard/product-importer', 21 | icon: 'fa fa-cloud-upload', 22 | container: 'getoutfitted', 23 | template: 'dashboardProductImporter', 24 | name: 'dashboardProductImporter', 25 | workflow: 'productImporterWorkflow', 26 | priority: 2 27 | }, { 28 | provides: 'settings', 29 | label: 'Product Importer Settings', 30 | route: '/dashboard/product-importer/settings', 31 | name: 'settingsProductImporter', 32 | template: 'settingsProductImporter' 33 | } 34 | ], 35 | layout: [{ 36 | workflow: 'productImporterWorkflow', 37 | layout: 'coreLayout', 38 | theme: 'default', 39 | enabled: true, 40 | structure: { 41 | template: 'dashboardProductImporter', 42 | layoutHeader: 'goLayoutHeader', 43 | layoutFooter: 'goLayoutFooter', 44 | notFound: 'goNotFound', 45 | dashboardControls: 'dashboardControls', 46 | adminControlsFooter: 'adminControlsFooter' 47 | } 48 | }, { 49 | workflow: 'productImporterWorkflow', 50 | layout: 'getoutfittedLayout', 51 | theme: 'default', 52 | enabled: true, 53 | structure: { 54 | template: 'dashboardProductImporter', 55 | layoutHeader: 'goLayoutHeader', 56 | layoutFooter: 'goLayoutFooter', 57 | notFound: 'goNotFound', 58 | dashboardControls: 'dashboardControls', 59 | adminControlsFooter: 'adminControlsFooter' 60 | } 61 | }] 62 | }); 63 | -------------------------------------------------------------------------------- /server/methods/productImporter.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import { check } from 'meteor/check'; 3 | import { Packages } from '/lib/collections'; 4 | import { Reaction } from '/server/api'; 5 | import { _ } from 'meteor/underscore'; 6 | 7 | import { ProductImporter } from '../api'; 8 | 9 | Meteor.methods({ 10 | 'productImporter/importProducts': function (productsList) { 11 | check(productsList, [Object]); 12 | // group each Product by Product ID 13 | let productsById = ProductImporter.groupBy(productsList, 'productId'); 14 | _.each(productsById, function (product) { 15 | let productId = ProductImporter.createTopLevelProduct(product); 16 | let ancestors = [productId]; 17 | // group each variant by variant title 18 | let variantGroups = ProductImporter.groupBy(product, 'variantTitle'); 19 | _.each(variantGroups, function (variantGroup) { 20 | let variantGroupId = ProductImporter.createMidLevelVariant(variantGroup, ancestors); 21 | let variantAncestors = ancestors.concat([variantGroupId]); 22 | // create each sub variant 23 | _.each(variantGroup, function (variant) { 24 | ProductImporter.createVariant(variant, variantAncestors); 25 | }); 26 | }); 27 | }); 28 | return true; 29 | }, 30 | 'productImporter/addCustomField': function (productSelector, customField) { 31 | check(productSelector, String); 32 | check(customField, Object); 33 | let variantLevelToBeUpdated = 'settings.customFields.' + productSelector; 34 | let updateObj = {}; 35 | updateObj[variantLevelToBeUpdated] = customField; 36 | Packages.update({ 37 | name: 'reaction-product-importer', 38 | shopId: Reaction.getShopId() 39 | }, { 40 | $addToSet: updateObj 41 | }); 42 | }, 43 | 'productImport/removeCustomField': function (removingField) { 44 | check(removingField, { 45 | level: String, 46 | csvColumnName: String, 47 | productFieldName: String, 48 | valueType: String 49 | }); 50 | let data = Packages.findOne({ 51 | name: 'reaction-product-importer', 52 | shopId: Reaction.getShopId() 53 | }); 54 | if (data) { 55 | let customFields = data.settings.customFields; 56 | _.each(customFields[removingField.level], function (field, index) { 57 | let csvColumnName = field.csvColumnName === removingField.csvColumnName; 58 | let productFieldName = field.productFieldName === removingField.productFieldName; 59 | let valueType = field.valueType === removingField.valueType; 60 | if (csvColumnName && productFieldName && valueType && index !== -1) { 61 | customFields[removingField.level].splice(index, 1); 62 | } 63 | }); 64 | Packages.update({_id: data._id}, { 65 | $set: { 66 | 'settings.customFields': customFields 67 | } 68 | }); 69 | } else { 70 | throw new Error('403 Cannot find package.'); 71 | } 72 | } 73 | }); 74 | 75 | -------------------------------------------------------------------------------- /client/templates/dashboard/dashboard.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import { Template } from 'meteor/templating'; 3 | import { Session } from 'meteor/session'; 4 | import { _ } from 'meteor/underscore'; 5 | // import Baby from 'babyparse' 6 | import './dashboard.html'; 7 | 8 | // Since Papa Parse has no export - this pacakge requires meteor add harrison:papa-parse to be added to project 9 | 10 | Template.dashboardProductImporter.onRendered(function () { 11 | Session.setDefault('importingProducts', false); 12 | }); 13 | 14 | Template.dashboardProductImporter.helpers({ 15 | importingProducts: function () { 16 | return Session.get('importingProducts'); 17 | }, 18 | importSize: function () { 19 | return Session.get('importSize'); 20 | } 21 | }); 22 | 23 | Template.dashboardProductImporter.events({ 24 | 'submit #import-products-csv-form': function (event) { 25 | event.preventDefault(); 26 | Papa.parse(event.target.csvImportProductsFile.files[0], { 27 | header: true, 28 | complete: function (results) { 29 | if (results && results.data) { 30 | Session.set('importSize', _.size(results.data)); 31 | Session.set('importingProducts', true); 32 | Meteor.call('productImporter/importProducts', results.data, function (err, result) { 33 | if (err) { 34 | Alerts.removeSeen(); 35 | Alerts.add('Error while importing ' + err, 'danger', { 36 | autoHide: true 37 | }); 38 | } else { 39 | Alerts.removeSeen(); 40 | Alerts.add('Products Successfully Imported', 'success', { 41 | autoHide: true 42 | }); 43 | Session.set('importingProducts', false); 44 | } 45 | }); 46 | } 47 | } 48 | }); 49 | }, 50 | 'click .downloadSample': function (event) { 51 | event.preventDefault(); 52 | let data = [{ 53 | productId: '1', 54 | topProductType: 'simple', 55 | productTitle: 'Basic Reaction Product', 56 | pageTitle: 'This is a basic product. You can do a lot with it.', 57 | vendor: 'Example Manufacturer', 58 | handle: 'example-product', 59 | variantTitle: 'Basic Example Variant', 60 | variantType: 'variant', 61 | title: 'Option 1 - Red Dwarf', 62 | optionTitle: 'Red', 63 | price: '19.99', 64 | qty: '19', 65 | weight: '35', 66 | taxable: 'true', 67 | hashtags: 'Hashtags, Womens, Red', 68 | metafields: 'Material=Cotten | Quality=Excellent', 69 | description: 'Sign in as administrator to edit.\nYou can clone this product from the product grid.' 70 | }]; 71 | let unparse = Papa.unparse(data); 72 | // let unparse = Pap.unparse(data); 73 | let csvData = new Blob([unparse], {type: 'text/csv;charset=utf-8;'}); 74 | let csvURL = window.URL.createObjectURL(csvData); 75 | let tempLink = document.createElement('a'); 76 | tempLink.href = csvURL; 77 | tempLink.setAttribute('download', 'productImporterTemplate.csv'); 78 | tempLink.click(); 79 | } 80 | }); 81 | -------------------------------------------------------------------------------- /client/templates/dashboard/dashboard.html: -------------------------------------------------------------------------------- 1 | 106 | -------------------------------------------------------------------------------- /client/templates/dashboard/customFields.html: -------------------------------------------------------------------------------- 1 | 116 | -------------------------------------------------------------------------------- /client/templates/dashboard/customFields.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import { Template } from 'meteor/templating'; 3 | import { Session } from 'meteor/session'; 4 | import { Reaction } from '/client/api'; 5 | import { Packages } from '/lib/collections'; 6 | 7 | import './customFields.html'; 8 | 9 | function getProductImporterPackage() { 10 | return Packages.findOne({ 11 | name: 'reaction-product-importer', 12 | shopId: Reaction.getShopId() 13 | }); 14 | } 15 | 16 | Template.customFields.onRendered(function () { 17 | Session.setDefault('ifArray', false); 18 | Session.setDefault('ifObject', false); 19 | Session.setDefault('arrayOfObjects', false); 20 | Session.setDefault('objectPropertiesCount', 0); 21 | }); 22 | 23 | Template.customFields.helpers({ 24 | anyCustomFields: function () { 25 | const productImporter = getProductImporterPackage(); 26 | return _.some(productImporter.settings.customFields, function (level) { 27 | return level.length > 0; 28 | }); 29 | }, 30 | customTopProducts: function () { 31 | const productImporter = getProductImporterPackage(); 32 | return productImporter.settings.customFields.topProduct; 33 | }, 34 | customMidVariant: function () { 35 | const productImporter = getProductImporterPackage(); 36 | return productImporter.settings.customFields.midVariant; 37 | }, 38 | customVariant: function () { 39 | const productImporter = getProductImporterPackage(); 40 | return productImporter.settings.customFields.variant; 41 | }, 42 | ifArray: function () { 43 | return Session.get('ifArray'); 44 | }, 45 | ifObject: function () { 46 | return Session.get('ifObject'); 47 | }, 48 | arrayOrObject: function () { 49 | return Session.get('ifArray') || Session.get('ifObject'); 50 | }, 51 | arrayOfObjects: function () { 52 | return Session.get('arrayOfObjects') 53 | }, 54 | valuesOfObjects: function () { 55 | let objectPropertiesCount = Session.get('objectPropertiesCount'); 56 | let valueFields = ''; 57 | _(objectPropertiesCount).times(function (n) { 58 | let m = n + 1; 59 | let input = "
" 60 | + "" 61 | + "" 65 | + "
" 66 | valueFields += input; 67 | }); 68 | return valueFields; 69 | } 70 | }); 71 | 72 | Template.customFields.events({ 73 | 'submit #customFieldsForm': function () { 74 | event.preventDefault(); 75 | let customField = {}; 76 | customField.csvColumnName = event.target.columnName.value.trim(); 77 | customField.productFieldName = event.target.productField.value.trim(); 78 | customField.valueType = event.target.typeSelector.value; 79 | const productSelector = event.target.productSelector.value; 80 | if (customField.valueType === 'array' || customField.valueType === 'object') { 81 | customField.options = {}; 82 | customField.options.delimiter = event.target.delimiterSymbol.value; 83 | customField.options.typeSelector = event.target.optionTypeSelector.value; 84 | } 85 | if (customField.valueType === 'array' && event.target.optionTypeSelector.value === 'object') { 86 | customField.options.arrayOfObjects = {}; 87 | customField.options.arrayOfObjects.propertyCount = parseInt(event.target.objectPropertiesCount.value, 10); 88 | customField.options.arrayOfObjects.delimiter = event.target.objectPropertiesDelimiter.value; 89 | _(customField.options.arrayOfObjects.propertyCount).times(function (n) { 90 | 91 | customField.options.arrayOfObjects[n] = event.target['objectPropertiesCount' + n].value; 92 | }) 93 | } 94 | 95 | let columnNameWhiteSpace = customField.csvColumnName.search(/\s/g); 96 | let productFieldNameWhiteSpace = customField.productFieldName.search(/\s/g); 97 | let noWhiteSpace = columnNameWhiteSpace + productFieldNameWhiteSpace === -2; 98 | if (noWhiteSpace) { 99 | Meteor.call('productImporter/addCustomField', productSelector, customField); 100 | } else { 101 | Alerts.removeSeen(); 102 | Alerts.add('No Spaces are allow in ColumnName or ProductFieldName', 'danger', { 103 | autoHide: true 104 | }); 105 | } 106 | event.target.columnName.value = ''; 107 | event.target.productField.value = ''; 108 | Session.set('ifArray', false); 109 | Session.set('ifObject', false); 110 | Session.set('arrayOfObjects', false); 111 | Session.set('objectPropertiesCount', 0); 112 | }, 113 | 'change form #typeSelector': function () { 114 | event.preventDefault(); 115 | let selectedType = event.target.value; 116 | if (selectedType === 'array') { 117 | Session.set('ifArray', true); 118 | Session.set('ifObject', false); 119 | } else if (selectedType === 'object') { 120 | Session.set('ifArray', false); 121 | Session.set('ifObject', true); 122 | } else { 123 | Session.set('ifArray', false); 124 | Session.set('ifObject', false); 125 | } 126 | }, 127 | 'change form #optionTypeSelector': function () { 128 | const selectedOption = event.target.value; 129 | if (selectedOption === 'object') { 130 | Session.set('arrayOfObjects', true) 131 | } else { 132 | Session.set('arrayOfObjects', false) 133 | } 134 | }, 135 | 'click .remove': function (event) { 136 | event.preventDefault(); 137 | let customRemoval = {}; 138 | customRemoval.level = event.currentTarget.dataset.level; 139 | customRemoval.csvColumnName = event.currentTarget.dataset.csvColumnName; 140 | customRemoval.productFieldName = event.currentTarget.dataset.productFieldName; 141 | customRemoval.valueType = event.currentTarget.dataset.valueType; 142 | if (Object.keys(customRemoval).length === 4) { 143 | Meteor.call('productImport/removeCustomField', customRemoval); 144 | } 145 | }, 146 | 'change form #objectPropertiesCount': function () { 147 | Session.set('objectPropertiesCount', parseInt(event.target.value, 10)); 148 | } 149 | }); 150 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # reaction-product-importer 2 | Product Importer allows you to bulk upload products from a CSV file. Each CSV requires a column called ProductId that groups variants together. Then you make the second level variant whatever you want and the third variant whatever you want. You can also control the mapping from one column to a corresponding DB field, or name your columns to correspond 3 | 4 | Here's an example of what basic csv import file could look like 5 | 6 | | productId | topProductType | productTitle | pageTitle | vendor | handle | variantTitle | variantType | title | optionTitle | price | qty | weight | taxable | hastags | metatags | description | 7 | |-----------|----------------|------------------------|----------------------------------------------------|----------------------|-----------------|-----------------------|-------------|----------------------|-------------|-------|-----|--------|---------|-----------------------|------------------------------------|------------------------------------------------------------------------------------| 8 | | 1 | simple | Basic Reaction Product | This is a basic product. You can do a lot with it. | Example Manufacturer | example-product | Basic Example Variant | variant | Option 1 - Red Dwarf | Red | 19.99 | 19 | 35 | true | Hashtags, Womens, Red | Material=Cotten, Quality=Excellent | Sign in as administrator to edit.You can clone this product from the product grid. | 9 | 10 | 11 | ### Custom Product Fields 12 | Product Importer supports the importing of your custom fields, you just need to help us map them out. You need to tell us what the column name is, what the field name is on your products, what type of value you are inserting and finally where to put this (top level product, grouping variant or bottom variant) 13 | 14 | ![image](https://cloud.githubusercontent.com/assets/1203639/14441445/04ff190e-fff2-11e5-9fef-b4c273e44ad9.png) 15 | 16 | ### What kind of fields does Product Importer support? 17 | - top level - there are certain fields that only apply to top level products (topProductType, pageTitle, handle, hastags, metafields, description) these need to be on the first of each product but can be filled out for each item. 18 | - hashtags - each hashtag (tag) need to be separated by a , 19 | - metafields - each key value pair need to be separated by , and each key needs to be separated from value by = 20 | - Custom Arrays - can be split by whatever symbol you enter into the delimiting field 21 | - Custom Objects - can be delimited by any field but key values must be separated by = 22 | 23 | _**Note:**_ Any custom field you have must be supported by your product schema or it will not import. 24 | 25 | Here's an example of what a more complex csv import file would look like - _custom fields have *italicized* headers_ 26 | 27 | | productId | *topProductFunctionalType* | topProductType | variantType | *variantFunctionalType* | productTitle | pageTitle | vendor | handle | *variantTitle* | *title* | optionTitle | price | qty | weight | taxable | hastags | metatags | description | *productType* | *location* | *colors* | *gender* | *cleaningBuffer* | 28 | |-----------|----------------------------|----------------|-------------|-------------------------|---------------------|--------------------------------------------------|-----------|---------------------|----------------|---------------|-------------------|-------|-----|--------|---------|-----------------------------------|------------------------------------------------------|-------------------|---------------|------------|------------------------|----------|------------------| 29 | | 1 | rental | simple | variant | rentalVariant | 3 in 1 | Womens Patagonia 3-in-1 | Patagonia | patagonia-3-in-1 | Small | Light Acai | Color-Acai | 19.99 | 2 | 35 | TRUE | Jacket, Winter | included=functional pockets |ProductDescription | Jacket | A1 | Acai,Marine | Womens | 0 | 30 | | 1 | rental | simple | variant | rentalVariant | 3 in 1 | Womens Patagonia 3-in-1 | Patagonia | patagonia-3-in-1 | Medium | Light Acai | Color-Acai | 20 | 6 | 35 | TRUE | Jacket, Winter | included=functional pockets |ProductDescription | Jacket | A1 | Acai,Marine | Womens | 0 | 31 | | 1 | rental | simple | variant | rentalVariant | 3 in 1 | Womens Patagonia 3-in-1 | Patagonia | patagonia-3-in-1 | Large | Light Acai | Color-Acai | 25 | 8 | 35 | TRUE | Jacket, Winter | included=functional pockets |ProductDescription | Jacket | A1 | Acai,Marine | Womens | 0 | 32 | | 1 | rental | simple | variant | rentalVariant | 3 in 1 | Womens Patagonia 3-in-1 | Patagonia | patagonia-3-in-1 | Extra Large | Light Acai | Color-Acai | 30 | 2 | 35 | TRUE | Jacket, Winter | included=functional pockets |ProductDescription | Jacket | A1 | Acai,Marine | Womens | 0 | 33 | | 1 | rental | simple | variant | rentalVariant | 3 in 1 | Womens Patagonia 3-in-1 | Patagonia | patagonia-3-in-1 | Small | Ultramarine | Color-Marine | 19.99 | 2 | 35 | TRUE | Jacket, Winter | included=functional pockets |ProductDescription | Jacket | A1 | Acai,Marine | Womens | 0 | 34 | | 1 | rental | simple | variant | rentalVariant | 3 in 1 | Womens Patagonia 3-in-1 | Patagonia | patagonia-3-in-1 | Medium | Ultramarine | Color-Marine | 20 | 6 | 35 | TRUE | Jacket, Winter | included=functional pockets |ProductDescription | Jacket | A1 | Acai,Marine | Womens | 0 | 35 | | 1 | rental | simple | variant | rentalVariant | 3 in 1 | Womens Patagonia 3-in-1 | Patagonia | patagonia-3-in-1 | Large | Ultramarine | Color-Marine | 25 | 8 | 35 | TRUE | Jacket, Winter | included=functional pockets |ProductDescription | Jacket | A1 | Acai,Marine | Womens | 0 | 36 | | 1 | rental | simple | variant | rentalVariant | 3 in 1 | Womens Patagonia 3-in-1 | Patagonia | patagonia-3-in-1 | Extra Large | Ultramarine | Color-Marine | 30 | 2 | 35 | TRUE | Jacket, Winter | included=functional pockets |ProductDescription | Jacket | A1 | Acai,Marine | Womens | 0 | 37 | | 2 | rental | simple | variant | rentalVariant | Snowbelle | Womens Patagonia Snowbelle | Patagonia | patagonia-snowbelle | Small | Black | Color-Black | 20 | 6 | 35 | TRUE | Pants, Winter | included=functional pockets |ProductDescription | Pants | B2 | Black | Womens | 0 | 38 | | 2 | rental | simple | variant | rentalVariant | Snowbelle | Womens Patagonia Snowbelle | Patagonia | patagonia-snowbelle | Medium | Black | Color-Black | 25 | 13 | 35 | TRUE | Pants, Winter | included=functional pockets |ProductDescription | Pants | B2 | Black | Womens | 0 | 39 | | 2 | rental | simple | variant | rentalVariant | Snowbelle | Womens Patagonia Snowbelle | Patagonia | patagonia-snowbelle | Large | Black | Color-Black | 25 | 13 | 35 | TRUE | Pants, Winter | included=functional pockets |ProductDescription | Pants | B2 | Black | Womens | 0 | 40 | | 2 | rental | simple | variant | rentalVariant | Snowbelle | Womens Patagonia Snowbelle | Patagonia | patagonia-snowbelle | Extra Large | Black | Color-Black | 35 | 5 | 35 | TRUE | Pants, Winter | included=functional pockets |ProductDescription | Pants | B2 | Black | Womens | 0 | 41 | 42 | This is our _Current Custom Fields_ section looks like for the above csv example. 43 | 44 | ![image](https://cloud.githubusercontent.com/assets/1203639/14441434/f2e191a2-fff1-11e5-88ba-dc6141d415e1.png) 45 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | 3 | "parser": "babel-eslint", 4 | 5 | "env": { 6 | 7 | "browser": true, 8 | 9 | "node": true, 10 | 11 | "meteor": true, 12 | 13 | }, 14 | 15 | "ecmaFeatures": { 16 | 17 | "arrowFunctions": true, 18 | 19 | "blockBindings": true, 20 | 21 | "classes": true, 22 | 23 | "defaultParams": true, 24 | 25 | "destructuring": true, 26 | 27 | "forOf": true, 28 | 29 | "generators": false, 30 | 31 | "modules": false, 32 | 33 | "objectLiteralComputedProperties": true, 34 | 35 | "objectLiteralDuplicateProperties": false, 36 | 37 | "objectLiteralShorthandMethods": true, 38 | 39 | "objectLiteralShorthandProperties": true, 40 | 41 | "spread": true, 42 | 43 | "superInFunctions": true, 44 | 45 | "templateStrings": true, 46 | 47 | "jsx": true 48 | 49 | }, 50 | 51 | "rules": { 52 | 53 | /** 54 | 55 | * Strict mode 56 | 57 | */ 58 | 59 | // babel inserts "use strict"; for us 60 | 61 | // http://eslint.org/docs/rules/strict 62 | 63 | "strict": [2, "never"], 64 | 65 | 66 | 67 | 68 | /** 69 | 70 | * ES6 71 | 72 | */ 73 | 74 | "no-var": 2, // http://eslint.org/docs/rules/no-var 75 | 76 | 77 | 78 | 79 | /** 80 | 81 | * Variables 82 | 83 | */ 84 | 85 | "no-shadow": 2, // http://eslint.org/docs/rules/no-shadow 86 | 87 | "no-shadow-restricted-names": 2, // http://eslint.org/docs/rules/no-shadow-restricted-names 88 | 89 | "no-unused-vars": [2, { // http://eslint.org/docs/rules/no-unused-vars 90 | 91 | "vars": "local", 92 | 93 | "args": "after-used" 94 | 95 | }], 96 | 97 | "no-const-assign": 2, // http://eslint.org/docs/rules/no-const-assign 98 | 99 | "no-use-before-define": [2, "nofunc"], // http://eslint.org/docs/rules/no-use-before-define 100 | 101 | 102 | 103 | 104 | /** 105 | 106 | * Possible errors 107 | 108 | */ 109 | 110 | "comma-dangle": [2, "never"], // http://eslint.org/docs/rules/comma-dangle 111 | 112 | "no-cond-assign": [2, "always"], // http://eslint.org/docs/rules/no-cond-assign 113 | 114 | "no-console": 1, // http://eslint.org/docs/rules/no-console 115 | 116 | "no-debugger": 1, // http://eslint.org/docs/rules/no-debugger 117 | 118 | "no-alert": 1, // http://eslint.org/docs/rules/no-alert 119 | 120 | "no-constant-condition": 1, // http://eslint.org/docs/rules/no-constant-condition 121 | 122 | "no-dupe-keys": 2, // http://eslint.org/docs/rules/no-dupe-keys 123 | 124 | "no-duplicate-case": 2, // http://eslint.org/docs/rules/no-duplicate-case 125 | 126 | "no-empty": 2, // http://eslint.org/docs/rules/no-empty 127 | 128 | "no-ex-assign": 2, // http://eslint.org/docs/rules/no-ex-assign 129 | 130 | "no-extra-boolean-cast": 0, // http://eslint.org/docs/rules/no-extra-boolean-cast 131 | 132 | "no-extra-semi": 2, // http://eslint.org/docs/rules/no-extra-semi 133 | 134 | "no-func-assign": 2, // http://eslint.org/docs/rules/no-func-assign 135 | 136 | "no-inner-declarations": 2, // http://eslint.org/docs/rules/no-inner-declarations 137 | 138 | "no-invalid-regexp": 2, // http://eslint.org/docs/rules/no-invalid-regexp 139 | 140 | "no-irregular-whitespace": 2, // http://eslint.org/docs/rules/no-irregular-whitespace 141 | 142 | "no-obj-calls": 2, // http://eslint.org/docs/rules/no-obj-calls 143 | 144 | "no-sparse-arrays": 2, // http://eslint.org/docs/rules/no-sparse-arrays 145 | 146 | "no-unreachable": 2, // http://eslint.org/docs/rules/no-unreachable 147 | 148 | "use-isnan": 2, // http://eslint.org/docs/rules/use-isnan 149 | 150 | "block-scoped-var": 0, // http://eslint.org/docs/rules/block-scoped-var 151 | 152 | 153 | 154 | 155 | /** 156 | 157 | * Best practices 158 | 159 | */ 160 | 161 | "consistent-return": 2, // http://eslint.org/docs/rules/consistent-return 162 | 163 | "curly": [2, "multi-line"], // http://eslint.org/docs/rules/curly 164 | 165 | "default-case": 2, // http://eslint.org/docs/rules/default-case 166 | 167 | "dot-notation": [2, { // http://eslint.org/docs/rules/dot-notation 168 | 169 | "allowKeywords": true 170 | 171 | }], 172 | 173 | "eqeqeq": 2, // http://eslint.org/docs/rules/eqeqeq 174 | 175 | "guard-for-in": 2, // http://eslint.org/docs/rules/guard-for-in 176 | 177 | "no-caller": 2, // http://eslint.org/docs/rules/no-caller 178 | 179 | "no-else-return": 2, // http://eslint.org/docs/rules/no-else-return 180 | 181 | "no-eq-null": 2, // http://eslint.org/docs/rules/no-eq-null 182 | 183 | "no-eval": 2, // http://eslint.org/docs/rules/no-eval 184 | 185 | "no-extend-native": 2, // http://eslint.org/docs/rules/no-extend-native 186 | 187 | "no-extra-bind": 2, // http://eslint.org/docs/rules/no-extra-bind 188 | 189 | "no-fallthrough": 2, // http://eslint.org/docs/rules/no-fallthrough 190 | 191 | "no-floating-decimal": 2, // http://eslint.org/docs/rules/no-floating-decimal 192 | 193 | "no-implied-eval": 2, // http://eslint.org/docs/rules/no-implied-eval 194 | 195 | "no-lone-blocks": 2, // http://eslint.org/docs/rules/no-lone-blocks 196 | 197 | "no-loop-func": 2, // http://eslint.org/docs/rules/no-loop-func 198 | 199 | "no-multi-str": 2, // http://eslint.org/docs/rules/no-multi-str 200 | 201 | "no-native-reassign": 2, // http://eslint.org/docs/rules/no-native-reassign 202 | 203 | "no-new": 2, // http://eslint.org/docs/rules/no-new 204 | 205 | "no-new-func": 2, // http://eslint.org/docs/rules/no-new-func 206 | 207 | "no-new-wrappers": 2, // http://eslint.org/docs/rules/no-new-wrappers 208 | 209 | "no-octal": 2, // http://eslint.org/docs/rules/no-octal 210 | 211 | "no-octal-escape": 2, // http://eslint.org/docs/rules/no-octal-escape 212 | 213 | "no-param-reassign": 2, // http://eslint.org/docs/rules/no-param-reassign 214 | 215 | "no-proto": 2, // http://eslint.org/docs/rules/no-proto 216 | 217 | "no-redeclare": 2, // http://eslint.org/docs/rules/no-redeclare 218 | 219 | "no-return-assign": 2, // http://eslint.org/docs/rules/no-return-assign 220 | 221 | "no-script-url": 2, // http://eslint.org/docs/rules/no-script-url 222 | 223 | "no-self-compare": 2, // http://eslint.org/docs/rules/no-self-compare 224 | 225 | "no-sequences": 2, // http://eslint.org/docs/rules/no-sequences 226 | 227 | "no-throw-literal": 2, // http://eslint.org/docs/rules/no-throw-literal 228 | 229 | "no-with": 2, // http://eslint.org/docs/rules/no-with 230 | 231 | "radix": 2, // http://eslint.org/docs/rules/radix 232 | 233 | "vars-on-top": 2, // http://eslint.org/docs/rules/vars-on-top 234 | 235 | "wrap-iife": [2, "any"], // http://eslint.org/docs/rules/wrap-iife 236 | 237 | "yoda": 2, // http://eslint.org/docs/rules/yoda 238 | 239 | "max-len": [2, 160, 2, { 240 | 241 | "ignoreComments": true, 242 | 243 | "ignoreUrls": true 244 | 245 | }], // http://eslint.org/docs/rules/max-len 246 | 247 | "valid-jsdoc": 2, // http://eslint.org/docs/rules/valid-jsdoc 248 | 249 | "quote-props": [2, "consistent-as-needed"], // http://eslint.org/docs/rules/quote-props 250 | 251 | "no-console": 2, // http://eslint.org/docs/rules/no-console 252 | 253 | 254 | 255 | 256 | /** 257 | 258 | * Style 259 | 260 | */ 261 | 262 | "indent": [2, 2], // http://eslint.org/docs/rules/indent 263 | 264 | "brace-style": [2, // http://eslint.org/docs/rules/brace-style 265 | 266 | "1tbs", { 267 | 268 | "allowSingleLine": true 269 | 270 | } 271 | 272 | ], 273 | 274 | "quotes": [ 275 | 276 | 2, "single", "avoid-escape" // http://eslint.org/docs/rules/quotes 277 | 278 | ], 279 | 280 | "camelcase": [2, { // http://eslint.org/docs/rules/camelcase 281 | 282 | "properties": "always" 283 | 284 | }], 285 | 286 | "comma-spacing": [2, { // http://eslint.org/docs/rules/comma-spacing 287 | 288 | "before": false, 289 | 290 | "after": true 291 | 292 | }], 293 | 294 | "comma-style": [2, "last"], // http://eslint.org/docs/rules/comma-style 295 | 296 | "eol-last": 2, // http://eslint.org/docs/rules/eol-last 297 | 298 | "func-names": 0, // http://eslint.org/docs/rules/func-names 299 | 300 | "func-style": [2, "declaration"], // http://eslint.org/docs/rules/func-style 301 | 302 | "key-spacing": [2, { // http://eslint.org/docs/rules/key-spacing 303 | 304 | "beforeColon": false, 305 | 306 | "afterColon": true 307 | 308 | }], 309 | 310 | "new-cap": [2, { // http://eslint.org/docs/rules/new-cap 311 | 312 | "newIsCap": true, 313 | 314 | "capIsNewExceptions": ["Optional"], 315 | 316 | }], 317 | 318 | "no-multiple-empty-lines": [2, { // http://eslint.org/docs/rules/no-multiple-empty-lines 319 | 320 | "max": 2 321 | 322 | }], 323 | 324 | "no-nested-ternary": 2, // http://eslint.org/docs/rules/no-nested-ternary 325 | 326 | "no-new-object": 2, // http://eslint.org/docs/rules/no-new-object 327 | 328 | "no-array-constructor": 2, // http://eslint.org/docs/rules/no-array-constructor 329 | 330 | "no-spaced-func": 2, // http://eslint.org/docs/rules/no-spaced-func 331 | 332 | "no-trailing-spaces": 2, // http://eslint.org/docs/rules/no-trailing-spaces 333 | 334 | "no-extra-parens": 2, // http://eslint.org/docs/rules/no-extra-parens 335 | 336 | "no-underscore-dangle": 0, // http://eslint.org/docs/rules/no-underscore-dangle 337 | 338 | "one-var": [2, "never"], // http://eslint.org/docs/rules/one-var 339 | 340 | "padded-blocks": [2, "never"], // http://eslint.org/docs/rules/padded-blocks 341 | 342 | "semi": [2, "always"], // http://eslint.org/docs/rules/semi 343 | 344 | "semi-spacing": [2, { // http://eslint.org/docs/rules/semi-spacing 345 | 346 | "before": false, 347 | 348 | "after": true 349 | 350 | }], 351 | 352 | "space-after-keywords": 2, // http://eslint.org/docs/rules/space-after-keywords 353 | 354 | "space-before-blocks": 2, // http://eslint.org/docs/rules/space-before-blocks 355 | 356 | "space-before-function-paren": [2, { 357 | 358 | "anonymous": "always", 359 | 360 | "named": "never" 361 | 362 | }], // http://eslint.org/docs/rules/space-before-function-paren 363 | 364 | "space-infix-ops": 2, // http://eslint.org/docs/rules/space-infix-ops 365 | 366 | "space-return-throw-case": 2, // http://eslint.org/docs/rules/space-return-throw-case 367 | 368 | "space-in-parens": [2, "never"], // http://eslint.org/docs/rules/space-in-parens 369 | 370 | "spaced-comment": [2, "always"], // http://eslint.org/docs/rules/spaced-comment 371 | 372 | } 373 | 374 | } 375 | -------------------------------------------------------------------------------- /server/api/productImporter.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import { check } from 'meteor/check'; 3 | import { Products, Packages } from '/lib/collections'; 4 | import { Reaction, Logger } from '/server/api'; 5 | import { _ } from 'meteor/underscore'; 6 | 7 | export const ProductImporter = {}; 8 | ProductImporter.existingProduct = function (product, type = 'variant') { 9 | check(product, Object); 10 | check(type, String); 11 | if (type !== 'variant') { 12 | return Products.findOne({ 13 | title: product.title, 14 | vendor: product.vendor, 15 | ancestors: product.ancestors, 16 | type: type, 17 | handle: product.handle 18 | }); 19 | } 20 | return Products.findOne({ 21 | title: product.title, 22 | ancestors: product.ancestors, 23 | type: type 24 | }); 25 | }; 26 | 27 | ProductImporter.anyCustomFields = function (level) { 28 | check(level, String); 29 | let validLevels = ['topProduct', 'midVariant', 'variant']; 30 | if (!_.contains(validLevels, level)) { 31 | Logger.warn('Customized Import does not match level'); 32 | return false; 33 | } 34 | let productImporter = Packages.findOne({ 35 | name: 'reaction-product-importer', 36 | shopId: Reaction.getShopId() 37 | }); 38 | if (productImporter) { 39 | return productImporter.settings.customFields[level].length >= 1; 40 | } 41 | }; 42 | ProductImporter.customFields = function (level) { 43 | check(level, String); 44 | let validLevels = ['topProduct', 'midVariant', 'variant']; 45 | 46 | if (!_.contains(validLevels, level)) { 47 | Logger.warn('Customized Import does not match level'); 48 | return false; 49 | } 50 | let productImporter = Packages.findOne({ 51 | name: 'reaction-product-importer', 52 | shopId: Reaction.getShopId() 53 | }); 54 | if (productImporter && productImporter.settings && productImporter.settings.customFields) { 55 | return productImporter.settings.customFields[level]; 56 | } 57 | }; 58 | 59 | ProductImporter.groupBy = function (productList, groupIdentifier) { 60 | check(productList, [Object]); 61 | check(groupIdentifier, String); 62 | return _.groupBy(productList, function (product) { 63 | return product[groupIdentifier]; 64 | }); 65 | }; 66 | 67 | ProductImporter.parseBasicType = function (value, valueType = 'string') { 68 | check(value, String); 69 | check(valueType, String); 70 | switch (valueType) { 71 | case 'number': 72 | return parseFloat(value, 10); 73 | case 'boolean': 74 | return JSON.parse(value.toLowerCase()); 75 | default: 76 | return value; 77 | } 78 | }; 79 | 80 | ProductImporter.parseByType = function (value, customField) { 81 | check(value, String); 82 | check(customField, Object); 83 | switch (customField.valueType) { 84 | case 'number': 85 | return parseFloat(value, 10); 86 | case 'boolean': 87 | return JSON.parse(value.toLowerCase()); 88 | case 'array': 89 | const arrayValues = value.split(customField.options.delimiter); 90 | if (customField.options.typeSelector === 'object') { 91 | let arrayOfObjects = []; 92 | _.each(arrayValues, function (stringObs) { 93 | let object = {}; 94 | let arrayOfKeyValues = stringObs.split(customField.options.arrayOfObjects.delimiter); 95 | _.each(arrayOfKeyValues, function (objectValue, index) { 96 | let keyValues = objectValue.split('='); 97 | let key = keyValues[0].trim(); 98 | let v = keyValues[1].trim(); 99 | object[key] = ProductImporter.parseBasicType(v, customField.options.arrayOfObjects[index]); 100 | }) 101 | arrayOfObjects.push(object); 102 | }); 103 | return arrayOfObjects; 104 | } else { 105 | const cleaned = _.map(arrayValues, function (arrayValue) { 106 | return ProductImporter.parseBasicType(arrayValue.trim(), customField.options.typeSelector); 107 | }); 108 | return cleaned; 109 | } 110 | case 'object': 111 | const objectValues = value.split(customField.options.delimiter); 112 | let customObject = {}; 113 | _.each(objectValues, function (objectValue) { 114 | let keyValues = objectValue.split('='); 115 | let key = keyValues[0].trim(); 116 | let v = keyValues[1].trim(); 117 | customObject[key] = ProductImporter.parseBasicType(v, customField.options.typeSelector); 118 | }); 119 | return customObject; 120 | default: 121 | return value; 122 | } 123 | }; 124 | 125 | ProductImporter.createTopLevelProduct = function (product) { 126 | check(product, [Object]); 127 | let baseProduct = product[0]; 128 | let sameProduct = _.every(product, function (item) { 129 | const result = baseProduct.productTitle === item.productTitle; 130 | return result; 131 | }); 132 | if (!sameProduct) { 133 | Logger.warn('One or more Products with productId ' + baseProduct.productId + ' have different product titles'); 134 | } 135 | let maxPricedProduct = _.max(product, function (item) { 136 | return parseInt(item.price, 10); 137 | }); 138 | let maxPrice = maxPricedProduct.price; 139 | let minPricedProduct = _.min(product, function (item) { 140 | return parseInt(item.price, 10); 141 | }); 142 | let minPrice = minPricedProduct.price; 143 | let prod = {}; 144 | prod.ancestors = []; 145 | prod.shopId = Reaction.getShopId(); 146 | prod.title = baseProduct.productTitle; 147 | prod.vendor = baseProduct.vendor; 148 | prod.pageTitle = baseProduct.pageTitle; 149 | prod.handle = baseProduct.handle.toLowerCase().trim(); 150 | prod.handle = prod.handle.replace(/\s/, '-'); 151 | prod.isVisible = false; 152 | prod.description = baseProduct.description; 153 | prod.type = baseProduct.topProductType || 'simple'; 154 | prod.price = {}; 155 | prod.price.max = maxPrice; 156 | prod.price.min = minPrice; 157 | if (maxPrice > minPrice) { 158 | prod.price.range = minPrice + ' - ' + maxPrice; 159 | } else { 160 | prod.price.range = minPrice; 161 | } 162 | 163 | if (baseProduct.metafields) { 164 | let delimited = baseProduct.metafields.split('|'); 165 | prod.metafields = []; 166 | _.each(delimited, function (objectValue) { 167 | let metafield = {}; 168 | let keyValues = objectValue.split('='); 169 | let key = keyValues[0].trim(); 170 | let v = keyValues[1].trim(); 171 | metafield.key = key; 172 | metafield.value = v; 173 | prod.metafields.push(metafield); 174 | }); 175 | } 176 | if (this.anyCustomFields('topProduct')) { 177 | let customFields = this.customFields('topProduct'); 178 | _.each(customFields, function (customField) { 179 | let result = ProductImporter.parseByType(baseProduct[customField.csvColumnName], customField); 180 | prod[customField.productFieldName] = result; 181 | }); 182 | } 183 | let existingProduct = this.existingProduct(prod, prod.type); 184 | if (existingProduct) { 185 | Logger.warn('Found product = ' + existingProduct._id); 186 | Logger.warn(existingProduct.vendor + ' ' + existingProduct.title + ' has already been added.'); 187 | return existingProduct._id; 188 | } 189 | let reactionProductId = Products.insert(prod, {selector: {type: prod.type}}); 190 | let hashtags = baseProduct.hashtags.split(','); 191 | _.each(hashtags, function (hashtag) { 192 | Meteor.call('products/updateProductTags', reactionProductId, hashtag.trim(), null); 193 | }); 194 | Logger.info(prod.vendor + ' ' + prod.title + ' was successfully added to Products.'); 195 | return reactionProductId; 196 | }; 197 | 198 | ProductImporter.createMidLevelVariant = function (variant, ancestors) { 199 | check(variant, [Object]); 200 | check(ancestors, [String]); 201 | let baseVariant = variant[0]; 202 | let sameVariant = _.every(variant, function (item) { 203 | return baseVariant.variantTitle === item.variantTitle; 204 | }); 205 | if (!sameVariant) { 206 | Logger.warn('One or more Products with variantTitle ' + baseVariant.variantTitle + ' have different variant titles'); 207 | } 208 | let inventory = _.reduce(variant, function (sum, item) { 209 | return sum + parseInt(item.qty, 10); 210 | }, 0); 211 | let prod = {}; 212 | prod.ancestors = ancestors; 213 | prod.isVisible = false; 214 | prod.type = baseVariant.variantType || 'variant'; 215 | prod.title = baseVariant.variantTitle; 216 | prod.price = baseVariant.price; 217 | prod.inventoryQuantity = inventory; 218 | prod.weight = parseInt(baseVariant.weight, 10); 219 | prod.shopId = Reaction.getShopId(); 220 | prod.taxable = baseVariant.taxable.toLowerCase() === 'true'; 221 | if (this.anyCustomFields('midVariant')) { 222 | let customFields = this.customFields('midVariant'); 223 | _.each(customFields, function (customField) { 224 | let result = ProductImporter.parseByType(baseVariant[customField.csvColumnName], customField); 225 | prod[customField.productFieldName] = result; 226 | }); 227 | } 228 | let existingVariant = this.existingProduct(prod, prod.type); 229 | if (existingVariant) { 230 | Logger.warn('Found product = ' + existingVariant._id); 231 | Logger.warn(existingVariant.title + ' has already been added.'); 232 | return existingVariant._id; 233 | } 234 | let reactionVariantId = Products.insert(prod, {selector: {type: prod.type}}); 235 | Logger.info(prod.title + ' was successfully added to Products as a variant.'); 236 | return reactionVariantId; 237 | }; 238 | 239 | ProductImporter.createVariant = function (variant, ancestors) { 240 | check(variant, Object); 241 | check(ancestors, [String]); 242 | let prod = {}; 243 | prod.ancestors = ancestors; 244 | prod.isVisible = false; 245 | prod.type = variant.variantType || 'variant'; 246 | prod.title = variant.title; 247 | prod.optionTitle = variant.optionTitle; 248 | prod.price = variant.price; 249 | prod.inventoryQuantity = parseInt(variant.qty, 10); 250 | prod.weight = parseInt(variant.weight, 10); 251 | prod.shopId = Reaction.getShopId(); 252 | prod.taxable = variant.taxable.toLowerCase() === 'true'; 253 | if (this.anyCustomFields('variant')) { 254 | let customFields = this.customFields('variant'); 255 | _.each(customFields, function (customField) { 256 | let result = ProductImporter.parseByType(variant[customField.csvColumnName], customField); 257 | prod[customField.productFieldName] = result; 258 | }); 259 | } 260 | let existingVariant = this.existingProduct(prod, prod.type); 261 | if (existingVariant) { 262 | Logger.warn('Found product = ' + existingVariant._id); 263 | Logger.warn(existingVariant.title + ' has already been added.'); 264 | return existingVariant._id; 265 | } 266 | let reactionVariantId = Products.insert(prod, {selector: {type: prod.type}}); 267 | Logger.info(prod.title + ' was successfully added to Products as a variant.'); 268 | return reactionVariantId; 269 | }; 270 | --------------------------------------------------------------------------------