diff --git a/.meteor/versions b/.meteor/versions
index aed99a91fa1..95a92a0cc10 100644
--- a/.meteor/versions
+++ b/.meteor/versions
@@ -13,8 +13,8 @@ allow-deny@1.1.0
audit-argument-checks@1.0.7
autoupdate@1.4.0
babel-compiler@7.0.4
-babel-runtime@1.2.0
-base64@1.0.10
+babel-runtime@1.2.2
+base64@1.0.11
binary-heap@1.0.10
blaze@2.3.2
blaze-html-templates@1.1.2
@@ -25,7 +25,7 @@ browser-policy@1.1.0
browser-policy-common@1.0.11
browser-policy-content@1.1.0
browser-policy-framing@1.1.0
-caching-compiler@1.1.9
+caching-compiler@1.1.11
caching-html-compiler@1.1.2
callback-hook@1.1.0
check@1.3.0
@@ -42,11 +42,11 @@ dispatch:run-as-user@1.1.1
dynamic-import@0.3.0
ecmascript@0.10.4
ecmascript-runtime@0.5.0
-ecmascript-runtime-client@0.6.0
+ecmascript-runtime-client@0.6.2
ecmascript-runtime-server@0.5.0
ejson@1.1.0
email@1.2.3
-es5-shim@4.7.0
+es5-shim@4.7.3
facebook-config-ui@1.0.1
facebook-oauth@1.4.0
gadicc:blaze-react-component@1.4.0
@@ -62,7 +62,7 @@ johanbrook:publication-collector@1.1.0
jquery@1.11.11
juliancwirko:postcss@1.3.0
launch-screen@1.1.1
-less@2.7.11
+less@2.7.12
livedata@1.0.18
localstorage@1.2.0
logging@1.1.19
@@ -75,18 +75,18 @@ meteorhacks:subs-manager@1.6.4
meteortesting:browser-tests@0.1.2
meteortesting:mocha@0.5.0
minifier-css@1.3.1
-minifier-js@2.3.1
+minifier-js@2.3.2
minimongo@1.4.3
mobile-experience@1.0.5
mobile-status-bar@1.0.14
-modules@0.11.3
-modules-runtime@0.9.1
+modules@0.11.4
+modules-runtime@0.9.2
momentjs:moment@2.19.4
-mongo@1.4.2
+mongo@1.4.3
mongo-dev-server@1.1.0
mongo-id@1.0.6
npm-bcrypt@0.9.3
-npm-mongo@2.2.33
+npm-mongo@2.2.34
oauth@1.2.1
oauth-encryption@1.3.0
oauth1@1.2.0
@@ -98,10 +98,10 @@ percolate:migrations@0.9.8
practicalmeteor:chai@2.1.0_1
practicalmeteor:mocha-core@1.0.1
practicalmeteor:sinon@1.14.1_2
-promise@0.10.1
+promise@0.10.2
raix:eventemitter@0.1.3
random@1.1.0
-rate-limit@1.0.8
+rate-limit@1.0.9
reactive-dict@1.2.0
reactive-var@1.0.11
reload@1.2.0
@@ -117,7 +117,7 @@ socket-stream-client@0.1.0
spacebars@1.0.15
spacebars-compiler@1.1.3
srp@1.0.10
-standard-minifier-js@2.3.1
+standard-minifier-js@2.3.2
templating@1.3.2
templating-compiler@1.3.3
templating-runtime@1.3.2
diff --git a/imports/plugins/core/catalog/server/i18n/en.json b/imports/plugins/core/catalog/server/i18n/en.json
index f500e00acfd..99ed92cdbe5 100644
--- a/imports/plugins/core/catalog/server/i18n/en.json
+++ b/imports/plugins/core/catalog/server/i18n/en.json
@@ -4,6 +4,7 @@
"translation": {
"reaction-catalog": {
"admin": {
+ "catalogProductPublishSuccess": "Product published to catalog",
"shortcut": {
"catalogLabel": "Catalog",
"catalogTitle": "Catalog"
diff --git a/imports/plugins/core/catalog/server/index.js b/imports/plugins/core/catalog/server/index.js
index 3979f964b5a..0c507c4d963 100644
--- a/imports/plugins/core/catalog/server/index.js
+++ b/imports/plugins/core/catalog/server/index.js
@@ -1 +1,2 @@
import "./i18n";
+import "./methods/catalog";
diff --git a/imports/plugins/core/catalog/server/methods/catalog.app-test.js b/imports/plugins/core/catalog/server/methods/catalog.app-test.js
new file mode 100644
index 00000000000..26b42992ad5
--- /dev/null
+++ b/imports/plugins/core/catalog/server/methods/catalog.app-test.js
@@ -0,0 +1,142 @@
+/* eslint dot-notation: 0 */
+/* eslint prefer-arrow-callback:0 */
+import { Random } from "meteor/random";
+import { expect } from "meteor/practicalmeteor:chai";
+import { sinon } from "meteor/practicalmeteor:sinon";
+import { Roles } from "meteor/alanning:roles";
+import { createActiveShop } from "/server/imports/fixtures/shops";
+import { Reaction } from "/server/api";
+import * as Collections from "/lib/collections";
+import Fixtures from "/server/imports/fixtures";
+import { PublicationCollector } from "meteor/johanbrook:publication-collector";
+import { RevisionApi } from "/imports/plugins/core/revisions/lib/api/revisions";
+import { publishProductToCatalog } from "./catalog";
+
+Fixtures();
+
+describe("Catalog", function () {
+ const shopId = Random.id();
+ let sandbox;
+
+ beforeEach(function () {
+ createActiveShop({ _id: shopId });
+ sandbox = sinon.sandbox.create();
+ sandbox.stub(RevisionApi, "isRevisionControlEnabled", () => true);
+ });
+
+ afterEach(function () {
+ sandbox.restore();
+ });
+
+ describe("with products", function () {
+ const priceRangeA = {
+ range: "1.00 - 12.99",
+ min: 1.00,
+ max: 12.99
+ };
+
+ const priceRangeB = {
+ range: "12.99 - 19.99",
+ min: 12.99,
+ max: 19.99
+ };
+
+ beforeEach(function (done) {
+ Collections.Products.direct.remove({});
+ Collections.Catalog.remove({});
+
+ // a product with price range A, and not visible
+ const id1 = Collections.Products.insert({
+ ancestors: [],
+ title: "My Little Pony",
+ shopId,
+ type: "simple",
+ price: priceRangeA,
+ isVisible: false,
+ isLowQuantity: false,
+ isSoldOut: false,
+ isBackorder: false
+ });
+ // a product with price range B, and visible
+ const id2 = Collections.Products.insert({
+ ancestors: [],
+ title: "Shopkins - Peachy",
+ shopId,
+ price: priceRangeB,
+ type: "simple",
+ isVisible: true,
+ isLowQuantity: false,
+ isSoldOut: false,
+ isBackorder: false
+ });
+ // a product with price range A, and visible
+ const id3 = Collections.Products.insert({
+ ancestors: [],
+ title: "Fresh Tomatoes",
+ shopId,
+ price: priceRangeA,
+ type: "simple",
+ isVisible: true,
+ isLowQuantity: false,
+ isSoldOut: false,
+ isBackorder: false
+ });
+
+ Promise.all([
+ publishProductToCatalog(id1),
+ publishProductToCatalog(id2),
+ publishProductToCatalog(id3)
+ ]).then(() => {
+ done();
+ });
+ });
+
+ describe("Collection", function () {
+ it("should return 3 products from the Catalog", function () {
+ const products = Collections.Catalog.find({}).fetch();
+ expect(products.length).to.equal(3);
+ });
+ });
+
+ describe("Publication", function () {
+ it("should return 2 products from Products/get", function (done) {
+ const productScrollLimit = 24;
+ sandbox.stub(Reaction, "getShopId", () => shopId);
+ sandbox.stub(Roles, "userIsInRole", () => false);
+
+ const collector = new PublicationCollector({ userId: Random.id() });
+ let isDone = false;
+
+ collector.collect("Products/grid", productScrollLimit, undefined, {}, (collections) => {
+ const products = collections.Catalog;
+ expect(products.length).to.equal(2);
+
+ if (!isDone) {
+ isDone = true;
+ done();
+ }
+ });
+ });
+
+ it("should return one product in price.min query", function (done) {
+ const productScrollLimit = 24;
+ const filters = { "price.min": "2.00" };
+ sandbox.stub(Reaction, "getShopId", () => shopId);
+ sandbox.stub(Roles, "userIsInRole", () => false);
+
+ const collector = new PublicationCollector({ userId: Random.id() });
+ let isDone = false;
+
+ collector.collect("Products/grid", productScrollLimit, filters, {}, (collections) => {
+ const products = collections.Catalog;
+ expect(products.length).to.equal(1);
+
+ if (!isDone) {
+ isDone = true;
+ done();
+ }
+ });
+ });
+ });
+ });
+});
diff --git a/imports/plugins/core/catalog/server/methods/catalog.js b/imports/plugins/core/catalog/server/methods/catalog.js
new file mode 100644
index 00000000000..4075c82e39e
--- /dev/null
+++ b/imports/plugins/core/catalog/server/methods/catalog.js
@@ -0,0 +1,241 @@
+import { Meteor } from "meteor/meteor";
+import { check, Match } from "meteor/check";
+import { Products, Catalog as CatalogCollection } from "/lib/collections";
+import { Logger, Reaction } from "/server/api";
+import { Media } from "/imports/plugins/core/files/server";
+import { ProductRevision as Catalog } from "/imports/plugins/core/revisions/server/hooks";
+
+/**
+ * @method isSoldOut
+ * @summary We are to stop accepting new orders if product is marked as `isSoldOut`.
+ * @memberof Catalog
+ * @param {Array} variants - Array with top-level variants
+ * @return {Boolean} true if summary product quantity is zero.
+ */
+export function isSoldOut(variants) {
+ return variants.every((variant) => {
+ if (variant.inventoryManagement) {
+ return Catalog.getVariantQuantity(variant) <= 0;
+ }
+ return false;
+ });
+}
+
+/**
+ * @method isLowQuantity
+ * @summary If at least one of the variants is less than the threshold, then function returns `true`
+ * @memberof Catalog
+ * @param {Array} variants - array of child variants
+ * @return {boolean} low quantity or not
+ */
+export function isLowQuantity(variants) {
+ return variants.some((variant) => {
+ const quantity = Catalog.getVariantQuantity(variant);
+ // we need to keep an eye on `inventoryPolicy` too and qty > 0
+ if (variant.inventoryManagement && variant.inventoryPolicy && quantity) {
+ return quantity <= variant.lowInventoryWarningThreshold;
+ }
+ return false;
+ });
+}
+
+/**
+ * @method isBackorder
+ * @summary Is products variants is still available to be ordered after summary variants quantity is zero
+ * @memberof Catalog
+ * @param {Array} variants - array with variant objects
+ * @return {boolean} is backorder allowed or not for a product
+ */
+export function isBackorder(variants) {
+ return variants.every((variant) => !variant.inventoryPolicy && variant.inventoryManagement &&
+ variant.inventoryQuantity === 0);
+}
+
+/**
+ * @method publishProductToCatalog
+ * @summary Publish a product to the Catalog
+ * @memberof Catalog
+ * @param {string} productId - A string product id
+ * @return {boolean} true on successful publish, false if publish was unsuccessful
+ */
+export async function publishProductToCatalog(productId) {
+ check(productId, String);
+
+ // Find the product by id
+ let product = Products.findOne({
+ $or: [
+ { _id: productId },
+ { ancestors: { $in: [productId] } }
+ ]
+ });
+
+ // Stop if a product could not be found
+ if (!product) {
+ Logger.info("Cannot publish product to catalog");
+ return false;
+ }
+
+ // If the product has ancestors, then find to top product document
+ if (Array.isArray(product.ancestors) && product.ancestors.length) {
+ product = Products.findOne({
+ _id: product.ancestors[0]
+ });
+ }
+
+ // Get variants of the product
+ const variants = Products.find({
+ ancestors: {
+ $in: [productId]
+ }
+ }).fetch();
+
+ // Get Media for the product
+ const mediaArray = await Media.find({
+ "metadata.productId": productId,
+ "metadata.toGrid": 1,
+ "metadata.workflow": { $nin: ["archived", "unpublished"] }
+ }, {
+ sort: { "metadata.priority": 1, "uploadedAt": 1 }
+ });
+
+ // Denormalize media
+ const productMedia = mediaArray.map((media) => ({
+ metadata: media.metadata,
+ thumbnail: `${media.url({ store: "thumbnail" })}`,
+ small: `${media.url({ store: "small" })}`,
+ medium: `${media.url({ store: "medium" })}`,
+ large: `${media.url({ store: "large" })}`,
+ image: `${media.url({ store: "image" })}`
+ }));
+
+ // Denormalize product fields
+ product.media = productMedia;
+ product.type = "product-simple";
+ product.isSoldOut = isSoldOut(variants);
+ product.isBackorder = isBackorder(variants);
+ product.isLowQuantity = isLowQuantity(variants);
+ product.variants = variants.map((variant) => {
+ const { inventoryQuantity, ...v } = variant;
+ return v;
+ });
+
+ // Insert/update catalog document
+ const result = CatalogCollection.upsert({
+ _id: productId
+ }, {
+ $set: product
+ });
+
+ return result && result.numberAffected === 1;
+}
+
+/**
+ * @method publishProductsToCatalog
+ * @summary Publish one or more products to the Catalog
+ * @memberof Catalog
+ * @param {string|array} productIds - A string product id or an array of product ids
+ * @return {boolean} true on successful publish for all documents, false if one ore more fail to publish
+ */
+export function publishProductsToCatalog(productIds) {
+ check(productIds, Match.OneOf(String, Array));
+
+ let ids = productIds;
+ if (typeof ids === "string") {
+ ids = [productIds];
+ }
+
+ return ids.every(async (productId) => await publishProductToCatalog(productId));
+}
+
+/**
+ * @method publishProductInventoryAdjustments
+ * @summary Publish inventory updates for a single product to the Catalog
+ * @memberof Catalog
+ * @param {string} productId - A string product id
+ * @return {boolean} true on success, false on failure
+ */
+export function publishProductInventoryAdjustments(productId) {
+ check(productId, Match.OneOf(String, Array));
+
+ const catalogProduct = CatalogCollection.findOne({
+ _id: productId
+ });
+
+ if (!catalogProduct) {
+ Logger.info("Cannot publish inventory changes to catalog product");
+ return false;
+ }
+
+ const variants = Products.find({
+ ancestors: {
+ $in: [productId]
+ }
+ }).fetch();
+
+ const update = {
+ isSoldOut: isSoldOut(variants),
+ isBackorder: isBackorder(variants),
+ isLowQuantity: isLowQuantity(variants)
+ };
+
+ // Only apply changes of one these fields have changed
+ if (
+ update.isSoldOut !== catalogProduct.isSoldOut ||
+ update.isBackorder !== catalogProduct.isBackorder ||
+ update.isLowQuantity !== catalogProduct.isLowQuantity
+ ) {
+ const result = CatalogCollection.update({
+ _id: productId
+ }, {
+ $set: update
+ });
+
+ return result;
+ }
+
+ return false;
+}
+
+Meteor.methods({
+ "catalog/publish/products": (productIds) => {
+ check(productIds, Match.OneOf(String, Array));
+
+ // Ensure user has createProduct permission for active shop
+ if (!Reaction.hasPermission("createProduct")) {
+ Logger.error("Access Denied");
+ throw new Meteor.Error("access-denied", "Access Denied");
+ }
+
+ // Convert productIds if it's a string
+ let ids = productIds;
+ if (typeof ids === "string") {
+ ids = [productIds];
+ }
+
+ // Find all products
+ const productsToPublish = Products.find({
+ _id: { $in: ids }
+ }).fetch();
+
+ if (Array.isArray(productsToPublish)) {
+ const canUpdatePrimaryShopProducts = Reaction.hasPermission("createProduct", this.userId, Reaction.getPrimaryShopId());
+
+ const publisableProductIds = productsToPublish
+ // Only allow users to publish products for shops they permissions to createProductsFor
+ // If the user can createProducts on the main shop, they can publish products for all shops to the catalog.
+ .filter((product) => Reaction.hasPermission("createProduct", this.userId, product.shopId) || canUpdatePrimaryShopProducts)
+ .map((product) => product._id);
+
+ const success = publishProductsToCatalog(publisableProductIds);
+
+ if (!success) {
+ Logger.error("Some Products could not be published to the Catalog.");
+ throw new Meteor.Error("server-error", "Some Products could not be published to the Catalog.");
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+});
diff --git a/imports/plugins/core/revisions/client/components/publishControls.js b/imports/plugins/core/revisions/client/components/publishControls.js
index 057602a039c..8381547b691 100644
--- a/imports/plugins/core/revisions/client/components/publishControls.js
+++ b/imports/plugins/core/revisions/client/components/publishControls.js
@@ -214,11 +214,13 @@ class PublishControls extends Component {
buttonProps.i18nKeyLabel = "toolbar.publishAll";
}
+ const isDisabled = Array.isArray(this.props.documentIds) && this.props.documentIds.length === 0;
+
return (