diff --git a/bin/setup b/bin/setup index c373c9fbf06..4c2c3612dfc 100755 --- a/bin/setup +++ b/bin/setup @@ -22,6 +22,10 @@ function main() { add_new_env_vars } +function file_ends_with_newline() { + [[ $(tail -c1 "${env_file}" | wc -l) -gt 0 ]] +} + function add_new_env_vars() { # create .env and set perms if it does not exist [[ ! -f "${env_file}" ]] && { @@ -29,6 +33,11 @@ function add_new_env_vars() { chmod 0600 "${env_file}" } + # if the file does not end with new line add a new line to it + if ! file_ends_with_newline; then + echo "" >> "${env_file}" + fi + find . -name .env.example -type f -print0 | (xargs -0 grep -Ehv '^\s*#' || true) | sort | diff --git a/imports/collections/schemas/catalog.js b/imports/collections/schemas/catalog.js index a123f2a2e04..c43c39d2b98 100644 --- a/imports/collections/schemas/catalog.js +++ b/imports/collections/schemas/catalog.js @@ -73,29 +73,6 @@ export const ImageInfo = new SimpleSchema({ } }); -/** - * @name CatalogPriceRange - * @memberof Schemas - * @type {SimpleSchema} - * @property {Number} max required - * @property {Number} min required - * @property {String} range required - */ -export const CatalogPriceRange = new SimpleSchema({ - max: { - type: Number, - label: "Max price" - }, - min: { - type: Number, - label: "Min price" - }, - range: { - type: String, - label: "Price range" - } -}); - /** * @name SocialMetadata * @memberof Schemas @@ -140,8 +117,6 @@ export const SocialMetadata = new SimpleSchema({ * @property {Number} minOrderQuantity optional, default value: `1` * @property {String} optionTitle optional * @property {String} originCountry optional - * @property {CatalogPriceRange} price required - * @property {Object} pricing required * @property {ImageInfo} primaryImage optional * @property {String} shopId required * @property {String} sku optional @@ -255,14 +230,6 @@ export const VariantBaseSchema = new SimpleSchema({ label: "Origin country", optional: true }, - "price": { - type: Number - }, - "pricing": { - type: Object, - blackbox: true, - label: "Pricing" - }, "primaryImage": { type: ImageInfo, label: "Primary Image", @@ -349,8 +316,6 @@ export const CatalogVariantSchema = VariantBaseSchema.clone().extend({ * @property {String} originCountry optional * @property {String} pageTitle optional * @property {ShippingParcel} parcel optional - * @property {CatalogPriceRange} price optional - * @property {Object} pricing required * @property {ImageInfo} primaryImage optional * @property {String} productId required * @property {String} productType optional @@ -480,15 +445,6 @@ export const CatalogProduct = new SimpleSchema({ label: "Shipping parcel", optional: true }, - "price": { - type: CatalogPriceRange, - optional: true - }, - "pricing": { - type: Object, - blackbox: true, - label: "Pricing" - }, "primaryImage": { type: ImageInfo, label: "Primary Image", diff --git a/imports/collections/schemas/navigationTrees.js b/imports/collections/schemas/navigationTrees.js index 3cf712fdfaf..ce845fab6fa 100644 --- a/imports/collections/schemas/navigationTrees.js +++ b/imports/collections/schemas/navigationTrees.js @@ -16,6 +16,33 @@ const expanded = { optional: true }; +const isPrivate = { + label: "Admin access only", + type: Boolean, + defaultValue: false +}; + +const isSecondary = { + label: "Secondary nav only", + type: Boolean, + defaultValue: false +}; + +const isVisible = { + label: "Show in storefront", + type: Boolean, + defaultValue: true +}; + +const NavigationItem = { + navigationItemId, + expanded, + isVisible, + isPrivate, + isSecondary, + items +}; + /** * @name NavigationTreeItem * @memberof Schemas @@ -25,57 +52,40 @@ const expanded = { * @property {Array} items Child navigation items */ export const NavigationTreeItem = new SimpleSchema({ - navigationItemId, - expanded, - items, + ...NavigationItem, "items.$": { type: new SimpleSchema({ - navigationItemId, - expanded, - items, + ...NavigationItem, "items.$": { type: new SimpleSchema({ - expanded, - navigationItemId, - items, + ...NavigationItem, "items.$": { type: new SimpleSchema({ - expanded, - navigationItemId, - items, + ...NavigationItem, "items.$": { type: new SimpleSchema({ - expanded, - navigationItemId, - items, + ...NavigationItem, "items.$": { type: new SimpleSchema({ - expanded, - navigationItemId, - items, + ...NavigationItem, "items.$": { type: new SimpleSchema({ - expanded, - navigationItemId, - items, + ...NavigationItem, "items.$": { type: new SimpleSchema({ - expanded, - navigationItemId, - items, + ...NavigationItem, "items.$": { type: new SimpleSchema({ - expanded, - navigationItemId, - items, + ...NavigationItem, "items.$": { type: new SimpleSchema({ - expanded, - navigationItemId, - items, + ...NavigationItem, "items.$": { type: new SimpleSchema({ - navigationItemId + navigationItemId, + isVisible, + isPrivate, + isSecondary }) } }) diff --git a/imports/collections/schemas/products.js b/imports/collections/schemas/products.js index 765c57a122c..cbc7a50b479 100644 --- a/imports/collections/schemas/products.js +++ b/imports/collections/schemas/products.js @@ -53,7 +53,6 @@ registerSchema("VariantMedia", VariantMedia); * @property {String} _id required, Variant ID * @property {String[]} ancestors, default value: `[]` * @property {String} barcode optional - * @property {Number} compareAtPrice optional, Compare at price * @property {Date} createdAt optional * @property {Event[]} eventLog optional, Variant Event Log * @property {Number} height optional, default value: `0` @@ -74,7 +73,6 @@ registerSchema("VariantMedia", VariantMedia); * @property {Number} minOrderQuantity optional * @property {String} optionTitle, Option internal name, default value: `"Untitled option"` * @property {String} originCountry optional - * @property {Number} price, default value: `0.00` * @property {String} shopId required, Variant ShopId * @property {String} sku optional * @property {String} title, Label for customers, default value: `""` @@ -108,13 +106,6 @@ export const ProductVariant = new SimpleSchema({ } } }, - "compareAtPrice": { - label: "Compare At Price", - type: Number, - optional: true, - min: 0, - defaultValue: 0.00 - }, "createdAt": { label: "Created at", type: Date, @@ -250,13 +241,6 @@ export const ProductVariant = new SimpleSchema({ type: String, optional: true }, - "price": { - label: "Price", - type: Number, - defaultValue: 0.00, - min: 0, - optional: true - }, "shopId": { type: String, label: "Variant ShopId" @@ -312,33 +296,6 @@ export const ProductVariant = new SimpleSchema({ registerSchema("ProductVariant", ProductVariant); -/** - * @name PriceRange - * @type {SimpleSchema} - * @memberof Schemas - * @property {String} range, default value: `"0.00"` - * @property {Number} min optional, default value: `0` - * @property {Number} max optional, default value: `0` - */ -export const PriceRange = new SimpleSchema({ - range: { - type: String, - defaultValue: "0.00" - }, - min: { - type: Number, - defaultValue: 0, - optional: true - }, - max: { - type: Number, - defaultValue: 0, - optional: true - } -}); - -registerSchema("PriceRange", PriceRange); - /** * @name Product * @type {SimpleSchema} @@ -365,7 +322,6 @@ registerSchema("PriceRange", PriceRange); * @property {String} pageTitle optional * @property {ShippingParcel} parcel optional * @property {String} pinterestMsg optional - * @property {PriceRange} price denormalized, object with range string, min and max * @property {String} productType optional * @property {Date} publishedAt optional * @property {String} publishedProductHash optional @@ -496,10 +452,6 @@ export const Product = new SimpleSchema({ optional: true, max: 255 }, - "price": { - label: "Price", - type: PriceRange - }, "productType": { type: String, optional: true diff --git a/imports/node-app/core/ReactionNodeApp.js b/imports/node-app/core/ReactionNodeApp.js index 20370079d2f..25ec0890425 100644 --- a/imports/node-app/core/ReactionNodeApp.js +++ b/imports/node-app/core/ReactionNodeApp.js @@ -1,5 +1,6 @@ import { createServer } from "http"; import { PubSub } from "apollo-server"; +import { merge } from "lodash"; import mongodb, { MongoClient } from "mongodb"; import appEvents from "./util/appEvents"; import createApolloServer from "./createApolloServer"; @@ -32,41 +33,43 @@ export default class ReactionNodeApp { app: this, appEvents, collections: this.collections, - getFunctionsOfType(type) { - return ((options.functionsByType || {})[type]) || []; - }, + getFunctionsOfType: (type) => this.functionsByType[type] || [], // In a large production app, you may want to use an external pub-sub system. // See https://www.apollographql.com/docs/apollo-server/features/subscriptions.html#PubSub-Implementations // We may eventually bind this directly to Kafka. pubSub: new PubSub() }; - this.context.rootUrl = getRootUrl(); - this.context.getAbsoluteUrl = (path) => getAbsoluteUrl(this.context.rootUrl, path); + this.functionsByType = {}; + this.graphQL = { + resolvers: {}, + schemas: [] + }; - this.mongodb = options.mongodb || mongodb; + if (options.functionsByType) { + Object.keys(options.functionsByType).forEach((type) => { + if (!Array.isArray(this.functionsByType[type])) { + this.functionsByType[type] = []; + } + this.functionsByType[type].push(...options.functionsByType[type]); + }); + } - const { resolvers, schemas, graphiql } = options.graphQL; + if (options.graphQL) { + if (options.graphQL.resolvers) { + merge(this.graphQL.resolvers, options.graphQL.resolvers); + } + if (options.graphQL.schemas) { + this.graphQL.schemas.push(...options.graphQL.schemas); + } + } - const { - apolloServer, - expressApp, - path - } = createApolloServer({ - addCallMeteorMethod: this.options.addCallMeteorMethod || defaultAddCallMethod, - context: this.context, - debug: this.options.debug || false, - graphiql, - resolvers, - schemas - }); + this.context.rootUrl = getRootUrl(); + this.context.getAbsoluteUrl = (path) => getAbsoluteUrl(this.context.rootUrl, path); - this.apolloServer = apolloServer; - this.expressApp = expressApp; - this.graphQLPath = path; + this.registeredPlugins = {}; - // HTTP server for GraphQL subscription websocket handlers - this.httpServer = options.httpServer || createServer(this.expressApp); + this.mongodb = options.mongodb || mongodb; } setMongoDatabase(db) { @@ -99,17 +102,58 @@ export default class ReactionNodeApp { } async runServiceStartup() { - const { additionalServices = [], functionsByType = {} } = this.options; - - await Promise.all(additionalServices.map(async (service) => { - await service.startup(this.context); - })); - await Promise.all((functionsByType.startup || []).map(async (startupFunction) => { - await startupFunction(this.context); - })); + // Call `functionsByType.registerPluginHandler` functions for every plugin that + // has supplied one, passing in all other plugins. Allows one plugin to check + // for the presence of another plugin and read its config. + // + // These are not async but they run before plugin `startup` functions, so a plugin + // can save off relevant config and handle it later in `startup`. + const registerPluginHandlerFuncs = this.functionsByType.registerPluginHandler || []; + const packageInfoArray = Object.values(this.registeredPlugins); + registerPluginHandlerFuncs.forEach((registerPluginHandlerFunc) => { + if (typeof registerPluginHandlerFunc !== "function") { + throw new Error('A plugin registered a function of type "registerPluginHandler" which is not actually a function'); + } + packageInfoArray.forEach(registerPluginHandlerFunc); + }); + + const startupFunctionsRegisteredByPlugins = this.functionsByType.startup; + if (Array.isArray(startupFunctionsRegisteredByPlugins)) { + // We are intentionally running these in series, in the order in which they were registered + for (const startupFunction of startupFunctionsRegisteredByPlugins) { + await startupFunction(this.context); // eslint-disable-line no-await-in-loop + } + } + } + + initServer() { + const { addCallMeteorMethod, debug, httpServer } = this.options; + const { resolvers, schemas, graphiql } = this.graphQL; + + const { + apolloServer, + expressApp, + path + } = createApolloServer({ + addCallMeteorMethod: addCallMeteorMethod || defaultAddCallMethod, + context: this.context, + debug: debug || false, + graphiql, + resolvers, + schemas + }); + + this.apolloServer = apolloServer; + this.expressApp = expressApp; + this.graphQLPath = path; + + // HTTP server for GraphQL subscription websocket handlers + this.httpServer = httpServer || createServer(this.expressApp); } async startServer({ port }) { + if (!this.httpServer) this.initServer(); + return new Promise((resolve, reject) => { try { // To also listen for WebSocket connections for GraphQL @@ -155,4 +199,44 @@ export default class ReactionNodeApp { // (2) Stop the Express GraphQL server await this.stopServer(); } + + // Plugins should call this to register everything they provide. + // This is a non-Meteor replacement for the old `Reaction.registerPackage`. + async registerPlugin(plugin) { + if (typeof plugin.name !== "string" || plugin.name.length === 0) { + throw new Error("Plugin configuration passed to registerPlugin must have 'name' field"); + } + + if (this.registeredPlugins[plugin.name]) { + throw new Error(`You registered multiple plugins with the name "${plugin.name}"`); + } + + this.registeredPlugins[plugin.name] = plugin; + + if (plugin.graphQL) { + if (plugin.graphQL.resolvers) { + merge(this.graphQL.resolvers, plugin.graphQL.resolvers); + } + if (plugin.graphQL.schemas) { + this.graphQL.schemas.push(...plugin.graphQL.schemas); + } + } + + if (plugin.mutations) { + merge(this.context.mutations, plugin.mutations); + } + + if (plugin.queries) { + merge(this.context.queries, plugin.queries); + } + + if (plugin.functionsByType) { + Object.keys(plugin.functionsByType).forEach((type) => { + if (!Array.isArray(this.functionsByType[type])) { + this.functionsByType[type] = []; + } + this.functionsByType[type].push(...plugin.functionsByType[type]); + }); + } + } } diff --git a/imports/node-app/devserver/extendSchemas.js b/imports/node-app/devserver/extendSchemas.js index 5cb9784f846..0e6969d9e3d 100644 --- a/imports/node-app/devserver/extendSchemas.js +++ b/imports/node-app/devserver/extendSchemas.js @@ -1 +1,2 @@ import "/imports/plugins/core/taxes/lib/extendCoreSchemas"; +import "/imports/plugins/included/simple-pricing/server/extendCoreSchemas"; diff --git a/imports/node-app/devserver/index.js b/imports/node-app/devserver/index.js index 4fbbc07ba4f..9f4ca823f3d 100644 --- a/imports/node-app/devserver/index.js +++ b/imports/node-app/devserver/index.js @@ -6,6 +6,7 @@ import queries from "./queries"; import resolvers from "./resolvers"; import schemas from "./schemas"; import filesStartup from "./filesStartup"; +import registerPlugins from "./registerPlugins"; import "./extendSchemas"; const { MONGO_URL, PORT = 3030, ROOT_URL } = process.env; @@ -29,12 +30,15 @@ const app = new ReactionNodeApp({ } }); -// Serve files in the /public folder statically -app.expressApp.use(express.static("public")); +registerPlugins(app) + .then(() => { + // Serve files in the /public folder statically + app.expressApp.use(express.static("public")); -app.apolloServer.installSubscriptionHandlers(app.httpServer); + app.apolloServer.installSubscriptionHandlers(app.httpServer); -app.start({ mongoUrl: MONGO_URL, port: PORT }) + return app.start({ mongoUrl: MONGO_URL, port: PORT }); + }) .then(() => { Logger.info(`GraphQL listening at ${ROOT_URL}${app.apolloServer.graphqlPath}`); Logger.info(`GraphQL subscriptions ready at ${ROOT_URL.replace("http", "ws")}${app.apolloServer.subscriptionsPath}`); diff --git a/imports/node-app/devserver/queries.js b/imports/node-app/devserver/queries.js index d37b2860091..6caa2582bb2 100644 --- a/imports/node-app/devserver/queries.js +++ b/imports/node-app/devserver/queries.js @@ -12,6 +12,7 @@ import taxes from "/imports/plugins/core/taxes/server/no-meteor/queries"; import tags from "/imports/plugins/core/tags/server/no-meteor/queries"; // INCLUDED import shippingRates from "/imports/plugins/included/shipping-rates/server/no-meteor/queries"; +import simplePricing from "/imports/plugins/included/simple-pricing/server/no-meteor/queries"; export default merge( {}, @@ -25,5 +26,6 @@ export default merge( orders, taxes, tags, - shippingRates + shippingRates, + simplePricing ); diff --git a/imports/node-app/devserver/registerPlugins.js b/imports/node-app/devserver/registerPlugins.js new file mode 100644 index 00000000000..c7e66d39def --- /dev/null +++ b/imports/node-app/devserver/registerPlugins.js @@ -0,0 +1,11 @@ +import registerSimplePricingPlugin from "/imports/plugins/included/simple-pricing/server/no-meteor/register"; + +/** + * @summary A function in which you should call `register` function for each API plugin, + * in the order in which you want to register them. + * @param {ReactionNodeApp} app The ReactionNodeApp instance + * @return {Promise} Null + */ +export default async function registerPlugins(app) { + await registerSimplePricingPlugin(app); +} diff --git a/imports/node-app/devserver/resolvers.js b/imports/node-app/devserver/resolvers.js index 6dcbdae4767..6e778021e4d 100644 --- a/imports/node-app/devserver/resolvers.js +++ b/imports/node-app/devserver/resolvers.js @@ -14,6 +14,7 @@ import tags from "/imports/plugins/core/tags/server/no-meteor/resolvers"; import taxes from "/imports/plugins/core/taxes/server/no-meteor/resolvers"; // INCLUDED import shippingRates from "/imports/plugins/included/shipping-rates/server/no-meteor/resolvers"; +import simplePricing from "/imports/plugins/included/simple-pricing/server/no-meteor/resolvers"; export default merge( {}, @@ -29,5 +30,6 @@ export default merge( shipping, tags, taxes, - shippingRates + shippingRates, + simplePricing ); diff --git a/imports/node-app/devserver/schemas.js b/imports/node-app/devserver/schemas.js index 517d887455a..fd4d71b822e 100644 --- a/imports/node-app/devserver/schemas.js +++ b/imports/node-app/devserver/schemas.js @@ -16,6 +16,7 @@ import marketplace from "/imports/plugins/included/marketplace/server/no-meteor/ import paymentsExample from "/imports/plugins/included/payments-example/server/no-meteor/schemas"; import paymentsStripe from "/imports/plugins/included/payments-stripe/server/no-meteor/schemas"; import shippingRates from "/imports/plugins/included/shipping-rates/server/no-meteor/schemas"; +import simplePricing from "/imports/plugins/included/simple-pricing/server/no-meteor/schemas"; export default [ ...accounts, @@ -33,5 +34,6 @@ export default [ ...marketplace, ...paymentsExample, ...paymentsStripe, - ...shippingRates + ...shippingRates, + ...simplePricing ]; diff --git a/imports/plugins/core/accounts/client/components/login.js b/imports/plugins/core/accounts/client/components/login.js index 86818a947c8..c784827b159 100644 --- a/imports/plugins/core/accounts/client/components/login.js +++ b/imports/plugins/core/accounts/client/components/login.js @@ -20,8 +20,11 @@ class Login extends Component { constructor(props) { super(props); + const currentRoute = Router.current().route; + const isPasswordReset = currentRoute.name === "reset-password"; + this.state = { - currentView: props.loginFormCurrentView + currentView: isPasswordReset ? "loginFormUpdatePasswordView" : props.loginFormCurrentView }; this.showForgotPasswordView = this.showForgotPasswordView.bind(this); @@ -57,7 +60,7 @@ class Login extends Component { const currentRoute = Router.current().route; const isOauthFlow = currentRoute.options && currentRoute.options.meta && currentRoute.options.meta.oauthLoginFlow; const idpFormClass = isOauthFlow ? "idp-form" : ""; - if (this.state.currentView === "loginFormSignInView" || this.state.currentView === "loginFormSignUpView") { + if (this.state.currentView === "loginFormSignInView" || this.state.currentView === "loginFormSignUpView" || this.state.currentView === "loginFormUpdatePasswordView") { if (isOauthFlow) { return ( {this.props.isOpen === true && -
+
{showSpinner ? this.renderSpinnerOnLoad() :
diff --git a/imports/plugins/core/accounts/client/containers/auth.js b/imports/plugins/core/accounts/client/containers/auth.js index 487751c44f8..59a4eb76ed6 100644 --- a/imports/plugins/core/accounts/client/containers/auth.js +++ b/imports/plugins/core/accounts/client/containers/auth.js @@ -13,7 +13,7 @@ class AuthContainer extends Component { currentRoute: PropTypes.object, currentView: PropTypes.string, formMessages: PropTypes.object - } + }; constructor(props) { super(props); @@ -96,7 +96,7 @@ class AuthContainer extends Component { } }); } - } + }; hasError = (error) => { // True here means the field is valid @@ -106,18 +106,14 @@ class AuthContainer extends Component { } return false; - } + }; - formMessages = () => ( - - ) + formMessages = () => ; services = () => { const serviceHelper = new ServiceConfigHelper(); return serviceHelper.services(); - } + }; shouldShowSeperator = () => { const serviceHelper = new ServiceConfigHelper(); @@ -127,9 +123,9 @@ class AuthContainer extends Component { }); return !!Package["accounts-password"] && enabledServices.length > 0; - } + }; - capitalizeName = (str) => LoginFormSharedHelpers.capitalize(str) + capitalizeName = (str) => LoginFormSharedHelpers.capitalize(str); handleSocialLogin = (value) => { let serviceName = value; @@ -153,9 +149,9 @@ class AuthContainer extends Component { }); } }); - } + }; - hasPasswordService = () => !!Package["accounts-password"] + hasPasswordService = () => !!Package["accounts-password"]; renderAuthView() { if (this.props.currentView === "loginFormSignInView") { @@ -181,6 +177,17 @@ class AuthContainer extends Component { isLoading={this.state.isLoading} /> ); + } else if (this.props.currentView === "loginFormUpdatePasswordView") { + return ( + + ); } } diff --git a/imports/plugins/core/accounts/client/containers/updatePassword.js b/imports/plugins/core/accounts/client/containers/updatePassword.js index 490aecb23d0..11dfbb3143b 100644 --- a/imports/plugins/core/accounts/client/containers/updatePassword.js +++ b/imports/plugins/core/accounts/client/containers/updatePassword.js @@ -5,7 +5,7 @@ import Random from "@reactioncommerce/random"; import { Accounts } from "meteor/accounts-base"; import { Meteor } from "meteor/meteor"; import { Components, registerComponent } from "@reactioncommerce/reaction-components"; -import { Router } from "/client/api"; +import { Reaction, Router } from "/client/api"; import { LoginFormValidation } from "/lib/api"; import UpdatePassword from "../components/updatePassword"; @@ -15,7 +15,6 @@ const wrapComponent = (Comp) => ( callback: PropTypes.func, formMessages: PropTypes.object, isOpen: PropTypes.bool, - onCompleteRoute: PropTypes.string, type: PropTypes.string, uniqueId: PropTypes.string } @@ -76,7 +75,12 @@ const wrapComponent = (Comp) => ( } else { // Now that Meteor.users is verified, we should do the same with the Accounts collection Meteor.call("accounts/verifyAccount"); - Router.go(this.props.onCompleteRoute); + const { storefrontUrls } = Reaction.getCurrentShop(); + if (Reaction.hasAdminAccess()) { + Router.go("/operator"); + } else { + window.location.href = `${storefrontUrls.storefrontHomeUrl}/signin`; + } } }); } diff --git a/imports/plugins/core/accounts/server/no-meteor/queries/primaryShopId.js b/imports/plugins/core/accounts/server/no-meteor/queries/primaryShopId.js index 4caed081047..5f1f6cd0ce5 100644 --- a/imports/plugins/core/accounts/server/no-meteor/queries/primaryShopId.js +++ b/imports/plugins/core/accounts/server/no-meteor/queries/primaryShopId.js @@ -17,7 +17,7 @@ export default async function primaryShopId(collections) { if (typeof ROOT_URL !== "string") return null; const domain = url.parse(ROOT_URL).hostname; - const options = { fields: { _id: 1 } }; + const options = { projection: { _id: 1 } }; let shop = await Shops.findOne({ domains: domain }, options); diff --git a/imports/plugins/core/accounts/server/no-meteor/queries/primaryShopId.test.js b/imports/plugins/core/accounts/server/no-meteor/queries/primaryShopId.test.js index a78291c2e3e..25bbd871e6a 100644 --- a/imports/plugins/core/accounts/server/no-meteor/queries/primaryShopId.test.js +++ b/imports/plugins/core/accounts/server/no-meteor/queries/primaryShopId.test.js @@ -10,7 +10,7 @@ test("calls Shops.findOne with hostname query and returns result", async () => { expect(mockContext.collections.Shops.findOne).toHaveBeenCalledWith({ domains: "my.domain.com" }, { - fields: { + projection: { _id: 1 } }); @@ -27,14 +27,14 @@ test("returns ID of shop with shopType 'primary' if no domain results", async () expect(mockContext.collections.Shops.findOne).toHaveBeenCalledWith({ domains: "my.domain.com" }, { - fields: { + projection: { _id: 1 } }); expect(mockContext.collections.Shops.findOne).toHaveBeenCalledWith({ shopType: "primary" }, { - fields: { + projection: { _id: 1 } }); diff --git a/imports/plugins/core/cart/server/no-meteor/util/addCartItems.js b/imports/plugins/core/cart/server/no-meteor/util/addCartItems.js index 21942dba59f..dcbae8faced 100644 --- a/imports/plugins/core/cart/server/no-meteor/util/addCartItems.js +++ b/imports/plugins/core/cart/server/no-meteor/util/addCartItems.js @@ -2,7 +2,6 @@ import Random from "@reactioncommerce/random"; import SimpleSchema from "simpl-schema"; import { toFixed } from "accounting-js"; import ReactionError from "@reactioncommerce/reaction-error"; -import findProductAndVariant from "/imports/plugins/core/catalog/server/no-meteor/utils/findProductAndVariant"; const inputItemSchema = new SimpleSchema({ "metafields": { @@ -37,7 +36,7 @@ const inputItemSchema = new SimpleSchema({ * @return {Object} Object with `incorrectPriceFailures` and `minOrderQuantityFailures` and `updatedItemList` props */ export default async function addCartItems(context, currentItems, inputItems, options = {}) { - const { collections, queries } = context; + const { queries } = context; inputItemSchema.validate(inputItems); @@ -58,7 +57,7 @@ export default async function addCartItems(context, currentItems, inputItems, op catalogProduct, parentVariant, variant: chosenVariant - } = await findProductAndVariant(collections, productId, productVariantId); + } = await queries.findProductAndVariant(context, productId, productVariantId); const variantPriceInfo = await queries.getVariantPrice(context, chosenVariant, price.currencyCode); if (!variantPriceInfo) { diff --git a/imports/plugins/core/catalog/server/methods/catalog.app-test.js b/imports/plugins/core/catalog/server/methods/catalog.app-test.js index 5a858f04f70..e54fca0a367 100644 --- a/imports/plugins/core/catalog/server/methods/catalog.app-test.js +++ b/imports/plugins/core/catalog/server/methods/catalog.app-test.js @@ -527,75 +527,4 @@ describe("core product methods", function () { return done(); }); }); - - describe("publishProduct", function () { - it("should throw 403 error by non admin", function () { - sandbox.stub(Reaction, "hasPermission", () => false); - const product = addProduct(); - const updateProductSpy = sandbox.spy(Products, "update"); - expect(() => Meteor.call("products/publishProduct", product._id)).to.throw(ReactionError, /Access Denied/); - expect(updateProductSpy).to.not.have.been.called; - }); - - it("should let admin toggle product visibility", function () { - sandbox.stub(Reaction, "hasPermission", () => true); - let product = addProduct(); - const { isVisible } = product; - expect(() => Meteor.call("products/publishProduct", product._id)).to.not.throw(ReactionError, /Access Denied/); - product = Products.findOne(product._id); - expect(product.isVisible).to.equal(!isVisible); - }); - - it("should not publish product when missing title", function () { - sandbox.stub(Reaction, "hasPermission", () => true); - let product = addProduct(); - const { isVisible } = product; - Products.update(product._id, { - $set: { - title: "" - } - }, { - bypassCollection2: true - }); - - expect(() => Meteor.call("products/publishProduct", product._id)) - .to.throw(ReactionError, /Bad Request/); - - product = Products.findOne(product._id); - expect(product.isVisible).to.equal(isVisible); - }); - - it("should not publish product when missing even one of child variant price", function () { - sandbox.stub(Reaction, "hasPermission", () => true); - let product = addProduct(); - const { isVisible } = product; - const variant = Products.findOne({ ancestors: [product._id] }); - expect(variant.ancestors[0]).to.equal(product._id); - const options = Products.find({ - ancestors: [product._id, variant._id] - }).fetch(); - expect(options.length).to.equal(2); - Products.update(options[0]._id, { - $set: { - isVisible: true, - price: 0 - } - }, { - selector: { type: "variant" }, - validate: false - }); - product = Products.findOne(product._id); - expect(product.isVisible).to.equal(isVisible); - }); - - - it("should not publish product when missing variant", function () { - let product = addProduct(); - const { isVisible } = product; - sandbox.stub(Roles, "userIsInRole", () => true); - Products.remove({ ancestors: { $in: [product._id] } }); - product = Products.findOne(product._id); - expect(product.isVisible).to.equal(isVisible); - }); - }); }); diff --git a/imports/plugins/core/catalog/server/methods/catalog.js b/imports/plugins/core/catalog/server/methods/catalog.js index 54e7e33d5b7..0fe43fc0571 100644 --- a/imports/plugins/core/catalog/server/methods/catalog.js +++ b/imports/plugins/core/catalog/server/methods/catalog.js @@ -13,7 +13,6 @@ import appEvents from "/imports/node-app/core/util/appEvents"; import rawCollections from "/imports/collections/rawCollections"; import getGraphQLContextInMeteorMethod from "/imports/plugins/core/graphql/server/getGraphQLContextInMeteorMethod"; import hashProduct from "../no-meteor/mutations/hashProduct"; -import getProductPriceRange from "../no-meteor/utils/getProductPriceRange"; import getVariants from "../no-meteor/utils/getVariants"; import hasChildVariant from "../no-meteor/utils/hasChildVariant"; import isSoldOut from "/imports/plugins/core/inventory/server/no-meteor/utils/isSoldOut"; @@ -31,19 +30,6 @@ import isBackorder from "/imports/plugins/core/inventory/server/no-meteor/utils/ * @namespace Methods/Products */ -/** - * updateVariantProductField - * @private - * @summary updates the variant - * @param {Array} variants - the array of variants - * @param {String} field - the field to update - * @param {String} value - the value to add - * @return {Array} - return an array - */ -function updateVariantProductField(variants, field, value) { - return variants.map((variant) => Meteor.call("products/updateProductField", variant._id, field, value)); -} - /** * @array toDenormalize * @private @@ -51,7 +37,6 @@ function updateVariantProductField(variants, field, value) { * @type {string[]} */ const toDenormalize = [ - "price", "inventoryInStock", "lowInventoryWarningThreshold", "inventoryPolicy", @@ -211,7 +196,7 @@ function copyMedia(newId, variantOldId, variantNewId) { * @function denormalize * @private * @description With flattened model we do not want to get variant docs in - * `products` publication, but we need some data from variants to display price, + * `products` publication, but we need some data from variants to display * quantity, etc. That's why we are denormalizing these properties into product * doc. Also, this way should have a speed benefit comparing the way where we * could dynamically build denormalization inside `products` publication. @@ -219,7 +204,6 @@ function copyMedia(newId, variantOldId, variantNewId) { * removed * @param {String} id - product _id * @param {String} field - type of field. Could be: - * "price", * "inventoryInStock", * "inventoryManagement", * "inventoryPolicy", @@ -252,13 +236,8 @@ function denormalize(id, field) { isLowQuantity: Promise.await(isLowQuantity(variants, rawCollections)) }); break; - default: { - // "price" is object with range, min, max - const priceObject = Promise.await(getProductPriceRange(id, rawCollections)); - Object.assign(update, { - price: priceObject - }); - } + default: + return; } Products.update( @@ -316,6 +295,7 @@ function flushQuantity(id) { * @private * @description creates a product * @param {Object} props - initial product properties + * @param {Object} info - Other info * @return {Object} product - new product */ function createProduct(props = null, info = {}) { @@ -325,10 +305,10 @@ function createProduct(props = null, info = {}) { ...(props || {}) }; - if (newProductOrVariant.type === "variant") { - const userId = Reaction.getUserId(); - const context = Promise.await(getGraphQLContextInMeteorMethod(userId)); + const userId = Reaction.getUserId(); + const context = Promise.await(getGraphQLContextInMeteorMethod(userId)); + if (newProductOrVariant.type === "variant") { // Apply custom transformations from plugins. for (const customFunc of context.getFunctionsOfType("mutateNewVariantBeforeCreate")) { // Functions of type "mutateNewVariantBeforeCreate" are expected to mutate the provided variant. @@ -344,13 +324,10 @@ function createProduct(props = null, info = {}) { } } - // Price is required on products - if (!newProductOrVariant.price) { - newProductOrVariant.price = { - range: "0.00 - 0.00", - min: 0, - max: 0 - }; + // Apply custom transformations from plugins. + for (const customFunc of context.getFunctionsOfType("mutateNewProductBeforeCreate")) { + // Functions of type "mutateNewProductBeforeCreate" are expected to mutate the provided variant. + Promise.await(customFunc(newProductOrVariant, { context, ...info })); } } @@ -379,13 +356,6 @@ function updateCatalogProduct(userId, selector, modifier, validation) { Logger.error(`Error updating currentProductHash for product with ID ${product._id}`, error); }); - if (product.ancestors && product.ancestors[0]) { - // If update is variant, recalculate top-level product's price range - const topLevelProductId = product.ancestors[0]; - const price = Promise.await(getProductPriceRange(topLevelProductId, rawCollections)); - Products.update({ _id: topLevelProductId }, { $set: { price } }, { selector: { type: 'simple' } }); - } - return result; } @@ -408,10 +378,14 @@ Meteor.methods({ check(variantId, String); // Check first if Variant exists and then if user has the right to clone it - const variant = Products.findOne(variantId); + const variant = Products.findOne({ _id: variantId }); if (!variant) { throw new ReactionError("not-found", "Variant not found"); - } else if (!Reaction.hasPermission("createProduct", this.userId, variant.shopId)) { + } + + const authUserId = Reaction.getUserId(); + + if (!Reaction.hasPermission("createProduct", authUserId, variant.shopId)) { throw new ReactionError("access-denied", "Access Denied"); } @@ -435,10 +409,12 @@ Meteor.methods({ ], type: "variant" }).fetch(); + // exit if we're trying to clone a ghost - if (variants.length === 0) { - return; - } + if (variants.length === 0) return []; + + const context = Promise.await(getGraphQLContextInMeteorMethod(authUserId)); + const variantNewId = Random.id(); // for the parent variant // we need to make sure that top level variant will be cloned first, his // descendants later. @@ -456,11 +432,7 @@ Meteor.methods({ Object.assign(clone, sortedVariant, { _id: variantNewId, title: `${sortedVariant.title} - copy`, - optionTitle: `${sortedVariant.optionTitle} - copy`, - price: `${sortedVariant.price}` ? `${sortedVariant.price}` : `${variant.price}`, - compareAtPrice: `${sortedVariant.compareAtPrice}` - ? `${sortedVariant.compareAtPrice}` - : `${variant.compareAtPrice}` + optionTitle: `${sortedVariant.optionTitle} - copy` }); } else { const parentIndex = sortedVariant.ancestors.indexOf(variantId); @@ -472,10 +444,6 @@ Meteor.methods({ ancestors: ancestorsClone, title: `${sortedVariant.title}`, optionTitle: `${sortedVariant.optionTitle}`, - price: `${sortedVariant.price}` ? `${sortedVariant.price}` : `${variant.price}`, - compareAtPrice: `${sortedVariant.compareAtPrice}` - ? `${sortedVariant.compareAtPrice}` - : `${variant.compareAtPrice}`, height: `${sortedVariant.height}`, width: `${sortedVariant.width}`, weight: `${sortedVariant.weight}`, @@ -487,6 +455,12 @@ Meteor.methods({ delete clone.inventoryInStock; delete clone.lowInventoryWarningThreshold; + // Apply custom transformations from plugins. + for (const customFunc of context.getFunctionsOfType("mutateNewVariantBeforeCreate")) { + // Functions of type "mutateNewVariantBeforeCreate" are expected to mutate the provided variant. + Promise.await(customFunc(clone, { context, isOption: clone.ancestors.length > 1 })); + } + copyMedia(productId, oldId, clone._id); let newId; @@ -556,8 +530,7 @@ Meteor.methods({ const isOption = ancestors.length > 1; if (isOption) { Object.assign(newVariant, { - title: `${parent.title} - Untitled option`, - price: 0.0 + title: `${parent.title} - Untitled option` }); } @@ -793,7 +766,6 @@ Meteor.methods({ * @memberof Methods/Products * @method * @summary when we create a new product, we create it with an empty variant. - * all products have a variant with pricing and details * @return {String} The new product ID */ "products/createProduct"() { @@ -808,7 +780,6 @@ Meteor.methods({ // Create a product variant createProduct({ ancestors: [newSimpleProduct._id], - price: 0.0, title: "", type: "variant" // needed for multi-schema }, { product: newSimpleProduct, parentVariant: null, isOption: false }); @@ -1022,13 +993,16 @@ Meteor.methods({ // If we get a result from the product update, // denormalize and attach results to top-level product if (result === 1) { - if (type === "variant" && toDenormalize.indexOf(field) >= 0) { - denormalize(doc.ancestors[0], field); + if (type === "variant") { + if (toDenormalize.indexOf(field) >= 0) { + denormalize(doc.ancestors[0], field); + } + appEvents.emit("afterVariantUpdate", { _id, field, value }); + } else { + appEvents.emit("afterProductUpdate", { _id, field, value }); } } - appEvents.emit("afterVariantUpdate", { _id, field, value }); - return update; }, @@ -1051,7 +1025,9 @@ Meteor.methods({ const product = Products.findOne(productId); if (!product) { throw new ReactionError("not-found", "Product not found"); - } else if (!Reaction.hasPermission("createProduct", this.userId, product.shopId)) { + } + + if (!Reaction.hasPermission("createProduct", this.userId, product.shopId)) { throw new ReactionError("access-denied", "Access Denied"); } @@ -1403,89 +1379,6 @@ Meteor.methods({ ); }, - /** - * @name products/publishProduct - * @memberof Methods/Products - * @method - * @summary publish (visibility) of product - * @todo hook into publishing flow - * @param {String} productId - productId - * @return {Boolean} product.isVisible - */ - "products/publishProduct"(productId) { - check(productId, String); - - // Check first if Product exists and then if user has the proper rights - const product = Products.findOne(productId); - if (!product) { - throw new ReactionError("not-found", "Product not found"); - } else if (!Reaction.hasPermission("createProduct", this.userId, product.shopId)) { - throw new ReactionError("access-denied", "Access Denied"); - } - - const variants = Products.find({ - ancestors: { - $in: [productId] - } - }).fetch(); - let variantValidator = true; - - if (typeof product === "object" && product.title && product.title.length > 1) { - if (variants.length > 0) { - variants.forEach((variant) => { - // if this is a top variant with children, we avoid it to check price - // because we using price of its children - const options = Promise.await(getVariants(variant._id, rawCollections)); - if ((variant.ancestors.length === 1 && !options.length) || variant.ancestors.length !== 1) { - if (!(typeof variant.price === "number" && variant.price > 0)) { - variantValidator = false; - } - } - // if variant has no title - if (typeof variant.title === "string" && !variant.title.length) { - variantValidator = false; - } - if (typeof variant.optionTitle === "string" && !variant.optionTitle.length) { - variantValidator = false; - } - }); - } else { - Logger.debug("invalid product visibility ", productId); - throw new ReactionError("invalid-parameter", "Variant is required"); - } - - if (!variantValidator) { - Logger.debug("invalid product visibility ", productId); - throw new ReactionError("invalid-parameter", "Some properties are missing."); - } - - // update product visibility - Logger.debug("toggle product visibility ", product._id, !product.isVisible); - - const res = updateCatalogProduct( - this.userId, - { - _id: product._id - }, - { - $set: { - isVisible: !product.isVisible - } - }, - { - selector: { type: "simple" } - } - ); - - // update product variants visibility - updateVariantProductField(variants, "isVisible", !product.isVisible); - // if collection updated we return new `isVisible` state - return res === 1 && !product.isVisible; - } - Logger.debug("invalid product visibility ", productId); - throw new ReactionError("invalid-parameter", "Bad Request"); - }, - /** * @name products/toggleVisibility * @memberof Methods/Products @@ -1499,7 +1392,7 @@ Meteor.methods({ check(productId, String); // Check first if Product exists and then if user has the proper rights - const product = Products.findOne(productId); + const product = Products.findOne({ _id: productId }); if (!product) { throw new ReactionError("not-found", "Product not found"); } @@ -1508,6 +1401,8 @@ Meteor.methods({ throw new ReactionError("access-denied", "Access Denied"); } + const newFieldValue = !product.isVisible; + const res = updateCatalogProduct( this.userId, { @@ -1515,7 +1410,7 @@ Meteor.methods({ }, { $set: { - isVisible: !product.isVisible + isVisible: newFieldValue } }, { @@ -1525,14 +1420,23 @@ Meteor.methods({ } ); - if (Array.isArray(product.ancestors) && product.ancestors.length) { - const updateId = product.ancestors[0] || product._id; - const updatedPriceRange = Promise.await(getProductPriceRange(updateId, rawCollections)); - - Meteor.call("products/updateProductField", updateId, "price", updatedPriceRange); + if (res === 1) { + if (product.type === "variant") { + appEvents.emit("afterVariantUpdate", { + _id: productId, + field: "isVisible", + value: newFieldValue + }); + } else { + appEvents.emit("afterProductUpdate", { + _id: productId, + field: "isVisible", + value: newFieldValue + }); + } } // if collection updated we return new `isVisible` state - return res === 1 && !product.isVisible; + return res === 1 && newFieldValue; } }); diff --git a/imports/plugins/core/catalog/server/no-meteor/mutations/hashProduct.js b/imports/plugins/core/catalog/server/no-meteor/mutations/hashProduct.js index b4089d7f155..e5578c739a1 100644 --- a/imports/plugins/core/catalog/server/no-meteor/mutations/hashProduct.js +++ b/imports/plugins/core/catalog/server/no-meteor/mutations/hashProduct.js @@ -1,5 +1,5 @@ import hash from "object-hash"; -import { customPublishedProductFields, customPublishedProductVariantFields } from "/imports/plugins/core/core/server/no-meteor/pluginRegistration"; +import { customPublishedProductFields, customPublishedProductVariantFields } from "../registration"; import getCatalogProductMedia from "../utils/getCatalogProductMedia"; import getTopLevelProduct from "../utils/getTopLevelProduct"; @@ -19,7 +19,6 @@ const productFieldsThatNeedPublishing = [ "pageTitle", "parcel", "pinterestMsg", - "price", "productType", "shopId", "supportedFulfillmentTypes", @@ -43,7 +42,6 @@ const variantFieldsThatNeedPublishing = [ "minOrderQuantity", "optionTitle", "originCountry", - "price", "shopId", "sku", "title", diff --git a/imports/plugins/core/catalog/server/no-meteor/mutations/hashProduct.test.js b/imports/plugins/core/catalog/server/no-meteor/mutations/hashProduct.test.js index c3eccf352e3..c38c9d6d07b 100644 --- a/imports/plugins/core/catalog/server/no-meteor/mutations/hashProduct.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/mutations/hashProduct.test.js @@ -57,11 +57,6 @@ const mockProduct = { weight: 7.77 }, pinterestMsg: "pinterestMessage", - price: { - max: 5.99, - min: 2.99, - range: "2.99 - 5.99" - }, media: [ { metadata: { diff --git a/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.test.js b/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.test.js index bd75b602e35..1ab540b013f 100644 --- a/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.test.js @@ -58,7 +58,6 @@ const mockVariants = [ ], minOrderQuantity: 0, originCountry: "US", - price: 0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -97,7 +96,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -147,11 +145,6 @@ const mockProduct = { weight: 7.77 }, pinterestMsg: "pinterestMessage", - price: { - max: 5.99, - min: 2.99, - range: "2.99 - 5.99" - }, media: [ { metadata: { @@ -212,16 +205,6 @@ const expectedOptionsResponse = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, - pricing: { - USD: { - compareAtPrice: 15, - displayPrice: "992.00", - maxPrice: 992.0, - minPrice: 992.0, - price: 992.0 - } - }, shop: { _id: opaqueShopId }, @@ -262,16 +245,6 @@ const expectedVariantsResponse = [ minOrderQuantity: 0, options: expectedOptionsResponse, originCountry: "US", - price: 0, - pricing: { - USD: { - compareAtPrice: 0, - displayPrice: "2.99 - 5.99", - maxPrice: 5.99, - minPrice: 2.99, - price: null - } - }, shop: { _id: opaqueShopId }, @@ -328,20 +301,6 @@ const expectedItemsResponse = { height: 6.66, weight: 7.77 }, - price: { - max: 5.99, - min: 2.99, - range: "2.99 - 5.99" - }, - pricing: { - USD: { - compareAtPrice: 4.56, - displayPrice: "2.99 - 5.99", - maxPrice: 5.99, - minPrice: 2.99, - price: null - } - }, productId: opaqueProductId, media: [ { diff --git a/imports/plugins/core/catalog/server/no-meteor/queries/findCatalogProductsAndVariants.js b/imports/plugins/core/catalog/server/no-meteor/queries/findCatalogProductsAndVariants.js new file mode 100644 index 00000000000..4dc1a6aaded --- /dev/null +++ b/imports/plugins/core/catalog/server/no-meteor/queries/findCatalogProductsAndVariants.js @@ -0,0 +1,36 @@ +/** + * @name findCatalogProductsAndVariants + * @summary Returns products in the Catalog collection that correspond to multiple variants. + * This is the same as the `findProductAndVariant` query, but this is more efficient when + * you need to look up multiple variants at the same time. + * @param {Object} context - App context + * @param {Object[]} variants - An array of objects that each have `productId` and `variantId` props. + * @returns {Array} products - An array of products, parent variant and variants in the catalog + */ +export default async function findCatalogProductsAndVariants(context, variants) { + const { collections: { Catalog } } = context; + const productIds = variants.map((variant) => variant.productId); + + const catalogProductItems = await Catalog.find({ + "product.productId": { $in: productIds }, + "product.isVisible": true, + "product.isDeleted": { $ne: true }, + "isDeleted": { $ne: true } + }).toArray(); + + const catalogProductsAndVariants = catalogProductItems.map((catalogProductItem) => { + const { product } = catalogProductItem; + const orderedVariant = variants.find((variant) => product.productId === variant.productId); + + const { parentVariant, variant } = context.queries.findVariantInCatalogProduct(product, orderedVariant.variantId); + + return { + catalogProductItem, + parentVariant, + product, + variant + }; + }); + + return catalogProductsAndVariants; +} diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/findProductAndVariant.js b/imports/plugins/core/catalog/server/no-meteor/queries/findProductAndVariant.js similarity index 59% rename from imports/plugins/core/catalog/server/no-meteor/utils/findProductAndVariant.js rename to imports/plugins/core/catalog/server/no-meteor/queries/findProductAndVariant.js index d608b9b7fcc..3162793435b 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/findProductAndVariant.js +++ b/imports/plugins/core/catalog/server/no-meteor/queries/findProductAndVariant.js @@ -1,14 +1,16 @@ import ReactionError from "@reactioncommerce/reaction-error"; -import findVariantInCatalogProduct from "./findVariantInCatalogProduct"; /** - * @param {Object} collections - Map of raw MongoDB collections + * @summary Given a product and variant ID, looks up the product in the Catalog and + * returns the catalog item, catalog product, parent catalog variant (if any), + * and catalog variant objects. + * @param {Object} context - App context * @param {String} productId The Products collections ID for the product * @param {String} variantId The Products collections ID for the variant of this product - * @returns {Object} { catalogProductItem, catalogProduct, variant } + * @returns {Object} { catalogProductItem, catalogProduct, parentVariant, variant } */ -export default async function findProductAndVariant(collections, productId, variantId) { - const { Catalog } = collections; +export default async function findProductAndVariant(context, productId, variantId) { + const { collections: { Catalog }, queries } = context; const catalogProductItem = await Catalog.findOne({ "product.productId": productId, @@ -23,7 +25,7 @@ export default async function findProductAndVariant(collections, productId, vari const catalogProduct = catalogProductItem.product; - const { parentVariant, variant } = findVariantInCatalogProduct(catalogProduct, variantId); + const { parentVariant, variant } = queries.findVariantInCatalogProduct(catalogProduct, variantId); if (!variant) { throw new ReactionError("invalid-param", `Product with ID ${productId} has no variant with ID ${variantId}`); } diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/findVariantInCatalogProduct.js b/imports/plugins/core/catalog/server/no-meteor/queries/findVariantInCatalogProduct.js similarity index 90% rename from imports/plugins/core/catalog/server/no-meteor/utils/findVariantInCatalogProduct.js rename to imports/plugins/core/catalog/server/no-meteor/queries/findVariantInCatalogProduct.js index 6f44d527c09..f81c4a3cbcb 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/findVariantInCatalogProduct.js +++ b/imports/plugins/core/catalog/server/no-meteor/queries/findVariantInCatalogProduct.js @@ -1,4 +1,7 @@ /** + * @summary Given a catalogProduct from the database, traverses it to find + * and return the variant or option with the given `variantId`. If it's + * an option, the `parentVariant` is also returned. * @param {Object} catalogProduct - The `product` property of a Catalog Item * @param {String} variantId - The variantId to look for * @returns {Object} Object with `variant` and `parentVariant` props. diff --git a/imports/plugins/core/catalog/server/no-meteor/queries/getCurrentCatalogPriceForProductConfiguration.js b/imports/plugins/core/catalog/server/no-meteor/queries/getCurrentCatalogPriceForProductConfiguration.js deleted file mode 100644 index 01e695b04a8..00000000000 --- a/imports/plugins/core/catalog/server/no-meteor/queries/getCurrentCatalogPriceForProductConfiguration.js +++ /dev/null @@ -1,26 +0,0 @@ -import findProductAndVariant from "../utils/findProductAndVariant"; - -/** - * @summary Returns the current price in the Catalog for the given product configuration - * @param {Object} productConfiguration The ProductConfiguration object - * @param {String} currencyCode The currency code - * @param {Object} collections Map of MongoDB collections - * @returns {Object} Object with `price` as the current price in the Catalog for the given product configuration. - * Also returns catalogProduct and catalogProductVariant docs in case you need them. - */ -export default async function getCurrentCatalogPriceForProductConfiguration(productConfiguration, currencyCode, collections) { - const { productId, productVariantId } = productConfiguration; - const { - catalogProduct, - variant: catalogProductVariant - } = await findProductAndVariant(collections, productId, productVariantId); - - const variantPriceInfo = (catalogProductVariant.pricing && catalogProductVariant.pricing[currencyCode]) || {}; - const price = variantPriceInfo.price || catalogProductVariant.price; - - return { - catalogProduct, - catalogProductVariant, - price - }; -} diff --git a/imports/plugins/core/catalog/server/no-meteor/queries/index.js b/imports/plugins/core/catalog/server/no-meteor/queries/index.js index ea44ceca81e..ffea8a61309 100644 --- a/imports/plugins/core/catalog/server/no-meteor/queries/index.js +++ b/imports/plugins/core/catalog/server/no-meteor/queries/index.js @@ -1,7 +1,9 @@ import catalogItems from "./catalogItems"; import catalogItemsAggregate from "./catalogItemsAggregate"; import catalogItemProduct from "./catalogItemProduct"; -import getCurrentCatalogPriceForProductConfiguration from "./getCurrentCatalogPriceForProductConfiguration"; +import findCatalogProductsAndVariants from "./findCatalogProductsAndVariants"; +import findProductAndVariant from "./findProductAndVariant"; +import findVariantInCatalogProduct from "./findVariantInCatalogProduct"; import tag from "./tag"; import tags from "./tags"; import tagsByIds from "./tagsByIds"; @@ -10,7 +12,9 @@ export default { catalogItems, catalogItemsAggregate, catalogItemProduct, - getCurrentCatalogPriceForProductConfiguration, + findCatalogProductsAndVariants, + findProductAndVariant, + findVariantInCatalogProduct, tag, tags, tagsByIds diff --git a/imports/plugins/core/catalog/server/no-meteor/registration.js b/imports/plugins/core/catalog/server/no-meteor/registration.js new file mode 100644 index 00000000000..9ae3adc8914 --- /dev/null +++ b/imports/plugins/core/catalog/server/no-meteor/registration.js @@ -0,0 +1,19 @@ +export const customPublishedProductFields = []; +export const customPublishedProductVariantFields = []; + +/** + * @summary Will be called for every plugin + * @param {Object} options The options object that the plugin passed to registerPackage + * @returns {undefined} + */ +export function registerPluginHandler({ catalog }) { + if (catalog) { + const { publishedProductFields, publishedProductVariantFields } = catalog; + if (Array.isArray(publishedProductFields)) { + customPublishedProductFields.push(...publishedProductFields); + } + if (Array.isArray(publishedProductVariantFields)) { + customPublishedProductVariantFields.push(...publishedProductVariantFields); + } + } +} diff --git a/imports/plugins/core/catalog/server/no-meteor/resolvers/CatalogProduct/index.js b/imports/plugins/core/catalog/server/no-meteor/resolvers/CatalogProduct/index.js index ed9672ed4fb..638d8fc9cbc 100644 --- a/imports/plugins/core/catalog/server/no-meteor/resolvers/CatalogProduct/index.js +++ b/imports/plugins/core/catalog/server/no-meteor/resolvers/CatalogProduct/index.js @@ -1,7 +1,6 @@ import { encodeCatalogProductOpaqueId, xformCatalogProductMedia } from "@reactioncommerce/reaction-graphql-xforms/catalogProduct"; import { encodeProductOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/product"; import { resolveShopFromShopId } from "@reactioncommerce/reaction-graphql-utils"; -import pricing from "./pricing"; import tagIds from "./tagIds"; import tags from "./tags"; @@ -9,7 +8,6 @@ export default { _id: (node) => encodeCatalogProductOpaqueId(node._id), productId: (node) => encodeProductOpaqueId(node.productId), shop: resolveShopFromShopId, - pricing, tagIds, tags, media: (node, args, context) => node.media && node.media.map((mediaItem) => xformCatalogProductMedia(mediaItem, context)), diff --git a/imports/plugins/core/catalog/server/no-meteor/resolvers/CatalogProduct/pricing.js b/imports/plugins/core/catalog/server/no-meteor/resolvers/CatalogProduct/pricing.js deleted file mode 100644 index 553c51dd7ee..00000000000 --- a/imports/plugins/core/catalog/server/no-meteor/resolvers/CatalogProduct/pricing.js +++ /dev/null @@ -1,5 +0,0 @@ -import { xformPricingArray } from "@reactioncommerce/reaction-graphql-xforms/product"; - -export default function pricing(catalogProduct) { - return xformPricingArray(catalogProduct.pricing); -} diff --git a/imports/plugins/core/catalog/server/no-meteor/resolvers/CatalogProductVariant/index.js b/imports/plugins/core/catalog/server/no-meteor/resolvers/CatalogProductVariant/index.js index 1af844a80b9..65c60c62025 100644 --- a/imports/plugins/core/catalog/server/no-meteor/resolvers/CatalogProductVariant/index.js +++ b/imports/plugins/core/catalog/server/no-meteor/resolvers/CatalogProductVariant/index.js @@ -1,5 +1,5 @@ import { encodeCatalogProductVariantOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/catalogProductVariant"; -import { encodeProductOpaqueId, xformPricingArray } from "@reactioncommerce/reaction-graphql-xforms/product"; +import { encodeProductOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/product"; import { resolveShopFromShopId } from "@reactioncommerce/reaction-graphql-utils"; import { xformCatalogProductMedia } from "@reactioncommerce/reaction-graphql-xforms/catalogProduct"; @@ -7,7 +7,6 @@ export default { _id: (node) => encodeCatalogProductVariantOpaqueId(node._id), variantId: (node) => encodeProductOpaqueId(node.variantId), shop: resolveShopFromShopId, - pricing: (node) => xformPricingArray(node.pricing), media: (node, args, context) => node.media && node.media.map((mediaItem) => xformCatalogProductMedia(mediaItem, context)), primaryImage: (node, args, context) => xformCatalogProductMedia(node.primaryImage, context) }; diff --git a/imports/plugins/core/catalog/server/no-meteor/resolvers/Query/catalogItems.js b/imports/plugins/core/catalog/server/no-meteor/resolvers/Query/catalogItems.js index fb54f9e5cce..a902bd93ae1 100644 --- a/imports/plugins/core/catalog/server/no-meteor/resolvers/Query/catalogItems.js +++ b/imports/plugins/core/catalog/server/no-meteor/resolvers/Query/catalogItems.js @@ -1,3 +1,4 @@ +import Logger from "@reactioncommerce/logger"; import { getPaginatedResponse, wasFieldRequested } from "@reactioncommerce/reaction-graphql-utils"; import { decodeShopOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/shop"; import { decodeTagOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/tag"; @@ -58,10 +59,9 @@ export default async function catalogItems(_, args, context, info) { } if (!realSortByField) { - if (typeof connectionArgs.sortByPriceCurrencyCode !== "string") { - throw new Error("sortByPriceCurrencyCode is required when sorting by minPrice"); - } - realSortByField = `product.pricing.${connectionArgs.sortByPriceCurrencyCode}.minPrice`; + Logger.warn("An attempt to sort catalog items by minPrice was rejected. " + + "Verify that you have a pricing plugin installed and it registers a getMinPriceSortByFieldPath function."); + throw new ReactionError("invalid-parameter", "Sorting by minPrice is not supported"); } connectionArgs.sortBy = realSortByField; diff --git a/imports/plugins/core/catalog/server/no-meteor/resolvers/index.js b/imports/plugins/core/catalog/server/no-meteor/resolvers/index.js index 265f2530ae1..4b1b1b0530b 100644 --- a/imports/plugins/core/catalog/server/no-meteor/resolvers/index.js +++ b/imports/plugins/core/catalog/server/no-meteor/resolvers/index.js @@ -6,7 +6,6 @@ import CatalogProductVariant from "./CatalogProductVariant"; import ImageInfo from "./ImageInfo"; import Mutation from "./Mutation"; import Query from "./Query"; -import ProductPricingInfo from "./ProductPricingInfo"; /** * Catalog-related GraphQL resolvers @@ -29,6 +28,5 @@ export default { ImageInfo, Mutation, Query, - ProductPricingInfo, ...getConnectionTypeResolvers("CatalogItem") }; diff --git a/imports/plugins/core/catalog/server/no-meteor/schemas/schema.graphql b/imports/plugins/core/catalog/server/no-meteor/schemas/schema.graphql index 15dd9238fc7..e5c8db1cb96 100644 --- a/imports/plugins/core/catalog/server/no-meteor/schemas/schema.graphql +++ b/imports/plugins/core/catalog/server/no-meteor/schemas/schema.graphql @@ -22,20 +22,6 @@ enum CatalogBooleanFilterName { isVisible } -"Represents the minimum and maximum price of a product, among all its variants" -type PriceRange { - "The price, in shop currency, of the most expensive possible variant with the most expensive possible option" - max: Float! - - "The price, in shop currency, of the least expensive possible variant with the least expensive possible option" - min: Float! - - """ - A range display string in the format "min - max", without any currency symbol - """ - range: String! -} - "A filter to be applied to a Catalog query" input CatalogBooleanFilter { "The name of the filter" @@ -45,75 +31,6 @@ input CatalogBooleanFilter { value: Boolean! } -"The product price or price range for a specific currency" -type ProductPricingInfo { - """ - A comparison price value, usually MSRP. If `price` is null, this will also be null. That is, - only purchasable variants will have a `compareAtPrice`. - """ - compareAtPrice: Money - - "The code for the currency these pricing details applies to" - currency: Currency! - - "Pricing converted to specified currency" - currencyExchangePricing( - currencyCode: String! - ): CurrencyExchangeProductPricingInfo - - """ - UI should display this price. If a product has multiple potential prices depending on selected - variants and options, then this is a price range string such as "$3.95 - $6.99". It includes the currency - symbols. - """ - displayPrice: String! - - "The price of the most expensive possible variant+option combination" - maxPrice: Float! - - "The price of the least expensive possible variant+option combination" - minPrice: Float! - - """ - For variants with no options and for options, this will always be set to a price. For variants - with options and products, this will be `null`. There must be a price for a variant to be - added to a cart or purchased. Otherwise you would instead add one of its child options to a cart. - """ - price: Float -} - -"The product price or price range for a specific currency" -type CurrencyExchangeProductPricingInfo { - """ - A comparison price value, usually MSRP. If `price` is null, this will also be null. That is, - only purchasable variants will have a `compareAtPrice`. - """ - compareAtPrice: Money - - "The code for the currency these pricing details applies to" - currency: Currency! - - """ - UI should display this price. If a product has multiple potential prices depending on selected - variants and options, then this is a price range string such as "$3.95 - $6.99". It includes the currency - symbols. - """ - displayPrice: String! - - "The price of the most expensive possible variant+option combination" - maxPrice: Float! - - "The price of the least expensive possible variant+option combination" - minPrice: Float! - - """ - For variants with no options and for options, this will always be set to a price. For variants - with options and products, this will be `null`. There must be a price for a variant to be - added to a cart or purchased. Otherwise you would instead add one of its child options to a cart. - """ - price: Float -} - "Holds metadata specific to a specific social network service" type SocialMetadata { "Default share message to use when sharing this product on this social network" @@ -257,12 +174,6 @@ type CatalogProduct implements CatalogProductOrVariant & Node { "Dimensions and other information about the containers in which this product will be shipped" parcel: ShippingParcel - "The range of prices among all variants (Deprecated use Pricing instead)" - price: PriceRange - - "Price and related information, per currency" - pricing: [ProductPricingInfo]! - "The primary image" primaryImage: ImageInfo @@ -389,12 +300,6 @@ type CatalogProductVariant implements CatalogProductOrVariant & Node { "The country of origin" originCountry: String - "The price, in the default shop currency (Deprecated use Pricing instead)" - price: Float! - - "Price and related information, per currency" - pricing: [ProductPricingInfo]! - "The primary image of this variant / option" primaryImage: ImageInfo diff --git a/imports/plugins/core/catalog/server/no-meteor/startup.js b/imports/plugins/core/catalog/server/no-meteor/startup.js index 6f18c290c8c..e6e9a6efae7 100644 --- a/imports/plugins/core/catalog/server/no-meteor/startup.js +++ b/imports/plugins/core/catalog/server/no-meteor/startup.js @@ -32,7 +32,7 @@ async function hashRelatedProduct(media, collections) { */ export default async function startup(context) { const { appEvents, collections } = context; - const { Catalog, Shops } = collections; + const { Catalog } = collections; // Create indexes @@ -40,17 +40,6 @@ export default async function startup(context) { // because all sorts include _id: 1 as secondary sort to be fully stable. collectionIndex(Catalog, { createdAt: 1, _id: 1 }); collectionIndex(Catalog, { updatedAt: 1, _id: 1 }); - - // Add an index to support built-in minPrice sorting for the primary shop's - // default currency code only. - const shop = await Shops.findOne({ shopType: "primary" }); - if (shop.currency) { - collectionIndex(Catalog, { - [`product.pricing.${shop.currency}.minPrice`]: 1, - _id: 1 - }); - } - collectionIndex(Catalog, { shopId: 1 }); collectionIndex(Catalog, { "product._id": 1 }); collectionIndex(Catalog, { "product.productId": 1 }); diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js index b61aec614dc..a71084768de 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js @@ -1,7 +1,6 @@ import Logger from "@reactioncommerce/logger"; import canBackorder from "./canBackorder"; import getCatalogProductMedia from "./getCatalogProductMedia"; -import getPriceRange from "./getPriceRange"; import isBackorder from "/imports/plugins/core/inventory/server/no-meteor/utils/isBackorder"; import isLowQuantity from "/imports/plugins/core/inventory/server/no-meteor/utils/isLowQuantity"; import isSoldOut from "/imports/plugins/core/inventory/server/no-meteor/utils/isSoldOut"; @@ -10,14 +9,12 @@ import isSoldOut from "/imports/plugins/core/inventory/server/no-meteor/utils/is * @method * @summary Converts a variant Product document into the catalog schema for variants * @param {Object} variant The variant from Products collection - * @param {Object} variantPriceInfo The result of calling getPriceRange for this price or all child prices - * @param {String} shopCurrencyCode The shop currency code for the shop to which this product belongs * @param {Object} variantMedia Media for this specific variant * @param {Object} variantInventory Inventory flags for this variant * @private * @returns {Object} The transformed variant */ -export function xformVariant(variant, variantPriceInfo, shopCurrencyCode, variantMedia, variantInventory) { +export function xformVariant(variant, variantMedia, variantInventory) { const primaryImage = variantMedia.find(({ toGrid }) => toGrid === 1) || null; return { @@ -41,16 +38,6 @@ export function xformVariant(variant, variantPriceInfo, shopCurrencyCode, varian minOrderQuantity: variant.minOrderQuantity, optionTitle: variant.optionTitle, originCountry: variant.originCountry, - price: variant.price, - pricing: { - [shopCurrencyCode]: { - compareAtPrice: variant.compareAtPrice || null, - displayPrice: variantPriceInfo.range, - maxPrice: variantPriceInfo.max, - minPrice: variantPriceInfo.min, - price: typeof variant.price === "number" ? variant.price : null - } - }, primaryImage, shopId: variant.shopId, sku: variant.sku, @@ -68,14 +55,10 @@ export function xformVariant(variant, variantPriceInfo, shopCurrencyCode, varian * @param {Object} data Data obj * @param {Object} data.collections Map of MongoDB collections by name * @param {Object} data.product The source product - * @param {Object} data.shop The Shop document for the shop that owns the product * @param {Object[]} data.variants The Product documents for all variants of this product * @returns {Object} The CatalogProduct document */ -export async function xformProduct({ collections, product, shop, variants }) { - const shopCurrencyCode = shop.currency; - const shopCurrencyInfo = shop.currencies[shopCurrencyCode]; - +export async function xformProduct({ collections, product, variants }) { const catalogProductMedia = await getCatalogProductMedia(product._id, collections); const primaryImage = catalogProductMedia.find(({ toGrid }) => toGrid === 1) || null; @@ -95,16 +78,12 @@ export async function xformProduct({ collections, product, shop, variants }) { } }); - const prices = []; const catalogProductVariants = topVariants // We want to explicitly map everything so that new properties added to variant are not published to a catalog unless we want them .map((variant) => { const variantOptions = options.get(variant._id); - let priceInfo; let variantInventory; if (variantOptions) { - const optionPrices = variantOptions.map((option) => option.price); - priceInfo = getPriceRange(optionPrices, shopCurrencyInfo); variantInventory = { canBackorder: canBackorder(variantOptions), inventoryAvailableToSell: variant.inventoryAvailableToSell || 0, @@ -114,7 +93,6 @@ export async function xformProduct({ collections, product, shop, variants }) { isSoldOut: isSoldOut(variantOptions) }; } else { - priceInfo = getPriceRange([variant.price], shopCurrencyInfo); variantInventory = { canBackorder: canBackorder([variant]), inventoryAvailableToSell: variant.inventoryAvailableToSell || 0, @@ -124,11 +102,10 @@ export async function xformProduct({ collections, product, shop, variants }) { isSoldOut: isSoldOut([variant]) }; } - prices.push(priceInfo.min, priceInfo.max); const variantMedia = catalogProductMedia.filter((media) => media.variantId === variant._id); - const newVariant = xformVariant(variant, priceInfo, shopCurrencyCode, variantMedia, variantInventory); + const newVariant = xformVariant(variant, variantMedia, variantInventory); if (variantOptions) { newVariant.options = variantOptions.map((option) => { @@ -141,14 +118,12 @@ export async function xformProduct({ collections, product, shop, variants }) { isLowQuantity: isLowQuantity([option]), isSoldOut: isSoldOut([option]) }; - return xformVariant(option, getPriceRange([option.price], shopCurrencyInfo), shopCurrencyCode, optionMedia, optionInventory); + return xformVariant(option, optionMedia, optionInventory); }); } return newVariant; }); - const productPriceInfo = getPriceRange(prices, shopCurrencyInfo); - return { // We want to explicitly map everything so that new properties added to product are not published to a catalog unless we want them _id: product._id, @@ -171,16 +146,6 @@ export async function xformProduct({ collections, product, shop, variants }) { originCountry: product.originCountry, pageTitle: product.pageTitle, parcel: product.parcel, - price: product.price, - pricing: { - [shop.currency]: { - compareAtPrice: product.compareAtPrice || null, - displayPrice: productPriceInfo.range, - maxPrice: productPriceInfo.max, - minPrice: productPriceInfo.min, - price: null - } - }, primaryImage, // The _id prop could change whereas this should always point back to the source product in Products collection productId: product._id, diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js index bc8b9a84c84..c7fba523800 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js @@ -55,7 +55,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Untitled Option", originCountry: "US", - price: 0, shopId: internalShopId, sku: "sku", title: "Small Concrete Pizza", @@ -96,7 +95,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", title: "One pound bag", @@ -146,11 +144,6 @@ const mockProduct = { weight: 7.77 }, pinterestMsg: "pinterestMessage", - price: { - max: 5.99, - min: 2.99, - range: "2.99 - 5.99" - }, media: [ { metadata: { @@ -242,20 +235,6 @@ const mockCatalogProduct = { weight: 7.77, width: 5.55 }, - price: { - max: 5.99, - min: 2.99, - range: "2.99 - 5.99" - }, - pricing: { - USD: { - compareAtPrice: null, - displayPrice: "$992.00", - maxPrice: 992, - minPrice: 992, - price: null - } - }, primaryImage: { URLs: { large: "large/path/to/image.jpg", @@ -359,16 +338,6 @@ const mockCatalogProduct = { minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992, - pricing: { - USD: { - compareAtPrice: null, - displayPrice: "$992.00", - maxPrice: 992, - minPrice: 992, - price: 992 - } - }, primaryImage: { URLs: { large: "large/path/to/image.jpg", @@ -391,16 +360,6 @@ const mockCatalogProduct = { width: 2 }], originCountry: "US", - price: 0, - pricing: { - USD: { - compareAtPrice: 1100, - displayPrice: "$992.00", - maxPrice: 992, - minPrice: 992, - price: 0 - } - }, primaryImage: null, shopId: "123", sku: "sku", diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/getProductPriceRange.js b/imports/plugins/core/catalog/server/no-meteor/utils/getProductPriceRange.js deleted file mode 100644 index b92229840bf..00000000000 --- a/imports/plugins/core/catalog/server/no-meteor/utils/getProductPriceRange.js +++ /dev/null @@ -1,32 +0,0 @@ -import getPriceRange from "./getPriceRange"; -import getVariants from "./getVariants"; -import getVariantPriceRange from "./getVariantPriceRange"; - -/** - * - * @method getProductPriceRange - * @summary Get the PriceRange object for a Product by ID - * @param {String} productId - A product ID - * @param {Object} collections - Raw mongo collections - * @return {Promise} Product PriceRange object - */ -export default async function getProductPriceRange(productId, collections) { - const { Products } = collections; - const product = await Products.findOne({ _id: productId }); - if (!product) { - throw new Error("Product not found"); - } - - const variants = await getVariants(product._id, collections, true); - const visibleVariants = variants.filter((option) => option.isVisible && !option.isDeleted); - if (visibleVariants.length > 0) { - const variantPrices = []; - await Promise.all(visibleVariants.map(async (variant) => { - const { min, max } = await getVariantPriceRange(variant._id, collections); - variantPrices.push(min, max); - })); - return getPriceRange(variantPrices); - } - - return getPriceRange([0]); -} diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/getProductPriceRange.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/getProductPriceRange.test.js deleted file mode 100644 index 3b2274be980..00000000000 --- a/imports/plugins/core/catalog/server/no-meteor/utils/getProductPriceRange.test.js +++ /dev/null @@ -1,213 +0,0 @@ -import mockContext from "/imports/test-utils/helpers/mockContext"; -import { rewire as rewire$getVariants, restore as restore$getVariants } from "./getVariants"; -import { rewire as rewire$getVariantPriceRange, restore as restore$getVariantPriceRange } from "./getVariantPriceRange"; -import getProductPriceRange from "./getProductPriceRange"; - -const mockCollections = { ...mockContext.collections }; -const mockGetVariants = jest.fn().mockName("getVariants"); -const mockGetVariantPriceRange = jest.fn().mockName("getVariantPriceRange"); - -const internalShopId = "123"; -const opaqueShopId = "cmVhY3Rpb24vc2hvcDoxMjM="; // reaction/shop:123 -const internalCatalogItemId = "999"; -const internalCatalogProductId = "999"; -const internalProductId = "999"; -const internalTagIds = ["923", "924"]; -const internalVariantIds = ["875", "874"]; - -const productSlug = "fake-product"; - -const createdAt = new Date("2018-04-16T15:34:28.043Z"); -const updatedAt = new Date("2018-04-17T15:34:28.043Z"); - -const mockVariants = [ - { - _id: internalVariantIds[0], - ancestors: [internalCatalogProductId], - barcode: "barcode", - createdAt, - height: 0, - index: 0, - inventoryManagement: true, - inventoryPolicy: false, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 0, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Untitled Option", - originCountry: "US", - price: 0, - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "Small Concrete Pizza", - updatedAt, - variantId: internalVariantIds[0], - weight: 0, - width: 0 - }, - { - _id: internalVariantIds[1], - ancestors: [internalCatalogProductId, internalVariantIds[0]], - barcode: "barcode", - height: 2, - index: 0, - inventoryManagement: true, - inventoryPolicy: true, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 2, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Awesome Soft Bike", - originCountry: "US", - price: 2.99, - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "One pound bag", - variantId: internalVariantIds[1], - weight: 2, - width: 2 - } -]; - -const mockProduct = { - _id: internalCatalogItemId, - shopId: internalShopId, - barcode: "barcode", - createdAt, - description: "description", - facebookMsg: "facebookMessage", - fulfillmentService: "fulfillmentService", - googleplusMsg: "googlePlusMessage", - height: 11.23, - isBackorder: false, - isLowQuantity: false, - isSoldOut: false, - length: 5.67, - lowInventoryWarningThreshold: 2, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - metaDescription: "metaDescription", - minOrderQuantity: 5, - originCountry: "originCountry", - pageTitle: "pageTitle", - parcel: { - containers: "containers", - length: 4.44, - width: 5.55, - height: 6.66, - weight: 7.77 - }, - pinterestMsg: "pinterestMessage", - price: { - max: 5.99, - min: 2.99, - range: "2.99 - 5.99" - }, - media: [ - { - metadata: { - toGrid: 1, - priority: 1, - productId: internalProductId, - variantId: null - }, - thumbnail: "http://localhost/thumbnail", - small: "http://localhost/small", - medium: "http://localhost/medium", - large: "http://localhost/large", - image: "http://localhost/original" - } - ], - productId: internalProductId, - productType: "productType", - shop: { - _id: opaqueShopId - }, - sku: "ABC123", - supportedFulfillmentTypes: ["shipping"], - handle: productSlug, - hashtags: internalTagIds, - title: "Fake Product Title", - twitterMsg: "twitterMessage", - type: "product-simple", - updatedAt, - mockVariants, - vendor: "vendor", - weight: 15.6, - width: 8.4 -}; - -const mockPriceRange = { - range: "2.99 - 5.99", - max: 5.99, - min: 2.99 -}; - -beforeAll(() => { - rewire$getVariants(mockGetVariants); - rewire$getVariantPriceRange(mockGetVariantPriceRange); -}); - -afterAll(() => { - restore$getVariants(); - restore$getVariantPriceRange(); -}); - -// expect a legit price range -test("expect to return a promise that resolves to a product price object", async () => { - mockCollections.Products.findOne.mockReturnValueOnce(Promise.resolve(mockProduct)); - mockGetVariants.mockReturnValueOnce(Promise.resolve(mockVariants)); - mockGetVariantPriceRange - .mockReturnValueOnce(Promise.resolve(mockPriceRange)) - .mockReturnValueOnce(Promise.resolve(mockPriceRange)); - const spec = await getProductPriceRange("999", mockCollections); - expect(spec).toEqual(mockPriceRange); -}); - -// expect an empty price range -test("expect to throw an error if no product is found", async () => { - mockCollections.Products.findOne.mockReturnValueOnce(Promise.resolve(undefined)); - try { - getProductPriceRange("badID", mockCollections); - } catch (error) { - expect(error).toEqual("Product not found"); - } -}); diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.test.js index a7a09fa9799..3f65c82e1ac 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.test.js @@ -46,7 +46,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Untitled Option", originCountry: "US", - price: 0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -85,7 +84,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -135,11 +133,6 @@ const mockProduct = { weight: 7.77 }, pinterestMsg: "pinterestMessage", - price: { - max: 5.99, - min: 2.99, - range: "2.99 - 5.99" - }, media: [ { metadata: { diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/getVariantPriceRange.js b/imports/plugins/core/catalog/server/no-meteor/utils/getVariantPriceRange.js deleted file mode 100644 index 8a1411993e4..00000000000 --- a/imports/plugins/core/catalog/server/no-meteor/utils/getVariantPriceRange.js +++ /dev/null @@ -1,27 +0,0 @@ -import getPriceRange from "./getPriceRange"; -import getVariants from "./getVariants"; - -/** - * - * @method getVariantPriceRange - * @summary Create a Product PriceRange object by taking the lowest variant price and the highest variant - * price to create the PriceRange. If only one variant use that variant's price to create the PriceRange - * @param {string} variantId - A product variant ID. - * @param {Object} collections - Raw mongo collections - * @return {Promise} Product PriceRange object - */ -export default async function getVariantPriceRange(variantId, collections) { - const { Products } = collections; - const options = await getVariants(variantId, collections); - const visibleOptions = options.filter((option) => option.isVisible && !option.isDeleted); - - if (visibleOptions.length === 0) { - const topVariant = await Products.findOne({ _id: variantId }); - // topVariant could be undefined when we removing last top variant - return topVariant && getPriceRange([topVariant.price]); - } - - const prices = visibleOptions.map((option) => option.price); - const price = getPriceRange(prices); - return price; -} diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/getVariantPriceRange.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/getVariantPriceRange.test.js deleted file mode 100644 index 6ad0b12132c..00000000000 --- a/imports/plugins/core/catalog/server/no-meteor/utils/getVariantPriceRange.test.js +++ /dev/null @@ -1,150 +0,0 @@ -import mockContext from "/imports/test-utils/helpers/mockContext"; -import { rewire as rewire$getVariants, restore as restore$getVariants } from "./getVariants"; -import getVariantPriceRange from "./getVariantPriceRange"; - -const mockCollections = { ...mockContext.collections }; -const mockGetVariants = jest.fn().mockName("getVariants"); - -const internalShopId = "123"; -const internalCatalogProductId = "999"; -const internalVariantIds = ["875", "874"]; - -const createdAt = new Date("2018-04-16T15:34:28.043Z"); -const updatedAt = new Date("2018-04-17T15:34:28.043Z"); - -const mockVariants = [ - { - _id: internalVariantIds[0], - ancestors: [internalCatalogProductId], - barcode: "barcode", - createdAt, - height: 0, - index: 0, - inventoryManagement: true, - inventoryPolicy: false, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 0, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Untitled Option", - originCountry: "US", - price: 2.99, - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "Small Concrete Pizza", - updatedAt, - variantId: internalVariantIds[0], - weight: 0, - width: 0 - }, - { - _id: internalVariantIds[1], - ancestors: [internalCatalogProductId, internalVariantIds[0]], - barcode: "barcode", - height: 2, - index: 0, - inventoryManagement: true, - inventoryPolicy: true, - isDeleted: false, - isLowQuantity: true, - isSoldOut: false, - isVisible: true, - length: 2, - lowInventoryWarningThreshold: 0, - metafields: [ - { - value: "value", - namespace: "namespace", - description: "description", - valueType: "valueType", - scope: "scope", - key: "key" - } - ], - minOrderQuantity: 0, - optionTitle: "Awesome Soft Bike", - originCountry: "US", - price: 5.99, - shopId: internalShopId, - sku: "sku", - taxCode: "0000", - taxDescription: "taxDescription", - title: "One pound bag", - variantId: internalVariantIds[1], - weight: 2, - width: 2 - } -]; - -beforeAll(() => { - rewire$getVariants(mockGetVariants); -}); - -afterAll(() => { - restore$getVariants(); -}); - -// expect topVariant price if no children -test("expect topVariants price string if no child variants", async () => { - mockGetVariants.mockReturnValueOnce(Promise.resolve([{ isDeleted: true }])); - mockCollections.Products.findOne.mockReturnValueOnce(Promise.resolve(mockVariants[0])); - const spec = await getVariantPriceRange(internalVariantIds[0], mockCollections); - const success = { - range: "2.99", - max: 2.99, - min: 2.99 - }; - expect(spec).toEqual(success); -}); - -// expect child variant price if only one child variant -test("expect child variant price string if only one child variant", async () => { - mockGetVariants.mockReturnValueOnce(Promise.resolve([mockVariants[1]])); - const spec = await getVariantPriceRange(internalVariantIds[0], mockCollections); - const success = { - range: "5.99", - max: 5.99, - min: 5.99 - }; - expect(spec).toEqual(success); -}); - -// expect a price rang string of the min price and max price -test("expect price range string if variants have different prices", async () => { - mockGetVariants.mockReturnValueOnce(Promise.resolve(mockVariants)); - const spec = await getVariantPriceRange(internalVariantIds[0], mockCollections); - const success = { - range: "2.99 - 5.99", - max: 5.99, - min: 2.99 - }; - expect(spec).toEqual(success); -}); - -// expect variant min price if min and max price are equal -test("expect variant price string if variants have same price", async () => { - mockVariants[1].price = 2.99; - mockGetVariants.mockReturnValueOnce(Promise.resolve(mockVariants)); - const spec = await getVariantPriceRange(internalVariantIds[0], mockCollections); - const success = { - range: "2.99", - max: 2.99, - min: 2.99 - }; - expect(spec).toEqual(success); -}); diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/getVariants.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/getVariants.test.js index dcdc1818187..db47c85450e 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/getVariants.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/getVariants.test.js @@ -40,7 +40,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Untitled Option", originCountry: "US", - price: 0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -78,7 +77,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/hasChildVariant.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/hasChildVariant.test.js index 7e9b7cc29b2..38659b99318 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/hasChildVariant.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/hasChildVariant.test.js @@ -40,7 +40,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Untitled Option", originCountry: "US", - price: 0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -78,7 +77,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.test.js index 7ce3198a75c..3ba83628d08 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.test.js @@ -51,10 +51,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Untitled Option", originCountry: "US", - price: 0, - pricing: { - blackbox: true - }, shopId: internalShopId, sku: "sku", title: "Small Concrete Pizza", @@ -92,10 +88,6 @@ const mockVariants = [ minOrderQuantity: 5, optionTitle: "Untitled Option 2", originCountry: "US", - price: 2.99, - pricing: { - blackbox: true - }, shopId: internalShopId, sku: "sku", title: "Small Concrete Pizza", @@ -142,14 +134,6 @@ const mockProduct = { height: 6.66, weight: 7.77 }, - price: { - max: 5.99, - min: 2.99, - range: "2.99 - 5.99" - }, - pricing: { - blackbox: true - }, productId: internalProductId, productType: "productType", shopId: internalShopId, @@ -205,11 +189,6 @@ const updatedMockProduct = { weight: 7.77 }, pinterestMsg: "pinterestMessage", - price: { - max: 5.99, - min: 2.99, - range: "2.99 - 5.99" - }, media: [ { metadata: { diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.test.js index da99cb86542..3ad712f1abb 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.test.js @@ -49,7 +49,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Untitled Option", originCountry: "US", - price: 0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -87,7 +86,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -136,11 +134,6 @@ const mockProduct = { weight: 7.77 }, pinterestMsg: "pinterestMessage", - price: { - max: 5.99, - min: 2.99, - range: "2.99 - 5.99" - }, media: [ { metadata: { diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.test.js index 233df3edc7f..6c8a656ec08 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.test.js @@ -49,7 +49,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Untitled Option", originCountry: "US", - price: 0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -87,7 +86,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -136,11 +134,6 @@ const mockProduct = { weight: 7.77 }, pinterestMsg: "pinterestMessage", - price: { - max: 5.99, - min: 2.99, - range: "2.99 - 5.99" - }, media: [ { metadata: { diff --git a/imports/plugins/core/components/lib/blocks.js b/imports/plugins/core/components/lib/blocks.js index 38da5a6f26e..cd4726fba66 100644 --- a/imports/plugins/core/components/lib/blocks.js +++ b/imports/plugins/core/components/lib/blocks.js @@ -195,24 +195,27 @@ export function getBlocks(regionName) { * @method * @summary Replace a Reaction component with a new component and optionally add one or more higher order components. * This function keeps track of the previous HOCs and wraps the new HOCs around previous ones - * @param {String} name The name of the component to register. - * @param {React.Component} newComponent Interchangeable/extendable component. - * @param {Function|Array} hocs The HOCs to compose with the raw component. + * @param {Object} options Object containing block information + * @param {String} options.region The region of the block that will be replaced + * @param {String} options.block The name of the block that will be replaced + * @param {React.Component} options.component Interchangeable/extendable component. + * @param {Function|Array} options.hocs The HOCs to compose with the raw component. * @returns {Function|React.Component} A component callable with Components[name] * @memberof Components/Helpers */ -export function replaceBlock({ regionName, blockName, newBlock, hocs = [] }) { - const previousBlock = BlocksTable[regionName][blockName]; +export function replaceBlock({ region, block, component, hocs = [] }) { + const previousBlock = BlocksTable[region][block]; if (!previousBlock) { - throw new Error(`Block '${name}' of region ${regionName} not found. Use registerComponent to create it.`); + throw new Error(`Block '${block}' of region ${region} not found. Use registerComponent to create it.`); } const newHocs = Array.isArray(hocs) ? hocs : [hocs]; return registerBlock({ - name, - component: newBlock, + name: block, + region, + component, hocs: [...newHocs, ...previousBlock.hocs] }); } diff --git a/imports/plugins/core/core/server/Reaction/Endpoints.js b/imports/plugins/core/core/server/Reaction/Endpoints.js index 31255b86444..e9ad7a31feb 100644 --- a/imports/plugins/core/core/server/Reaction/Endpoints.js +++ b/imports/plugins/core/core/server/Reaction/Endpoints.js @@ -17,7 +17,7 @@ import { WebApp } from "meteor/webapp"; const Endpoints = {}; WebApp.connectHandlers.use(bodyParser.json({ - limit: "200kb", // Override default request size + limit: "500kb", // Override default request size // Attach the raw body which is necessary for doing verifications for some webhooks verify(req, res, buf) { req.rawBody = buf; diff --git a/imports/plugins/core/core/server/Reaction/core.js b/imports/plugins/core/core/server/Reaction/core.js index 50a1c6f1bf1..a9bc1016014 100644 --- a/imports/plugins/core/core/server/Reaction/core.js +++ b/imports/plugins/core/core/server/Reaction/core.js @@ -9,15 +9,6 @@ import * as Collections from "/lib/collections"; import appEvents from "/imports/node-app/core/util/appEvents"; import { Jobs } from "/imports/utils/jobs"; import ConnectionDataStore from "/imports/plugins/core/core/server/util/connectionDataStore"; -import { - customPublishedProductFields, - customPublishedProductVariantFields, - functionsByType, - mutations, - queries, - resolvers, - schemas -} from "../no-meteor/pluginRegistration"; import createGroups from "./createGroups"; import { registerTemplate } from "./templates"; import { AbsoluteUrlMixin } from "./absoluteUrl"; @@ -57,18 +48,6 @@ export default { createGroups(); this.setAppVersion(); - // Call `functionsByType.registerPluginHandler` functions for every plugin that - // has supplied one, passing in all other plugins. Allows one plugin for check - // for the presence of another plugin and read its config. - const registerPluginHandlerFuncs = functionsByType.registerPluginHandler || []; - const packageInfoArray = Object.values(this.Packages); - registerPluginHandlerFuncs.forEach((registerPluginHandlerFunc) => { - if (typeof registerPluginHandlerFunc !== "function") { - throw new Error('A plugin registered a function of type "registerPluginHandler" which is not actually a function'); - } - packageInfoArray.forEach(registerPluginHandlerFunc); - }); - // DEPRECATED. Avoid consuming this hook in new code appEvents.emit("afterCoreInit"); @@ -79,49 +58,50 @@ export default { Packages: {}, - registerPackage(packageInfo) { - // Mutate globals with package info - if (packageInfo.graphQL) { - if (packageInfo.graphQL.resolvers) { - merge(resolvers, packageInfo.graphQL.resolvers); - } - if (packageInfo.graphQL.schemas) { - schemas.push(...packageInfo.graphQL.schemas); + /** + * @summary This is used only for the old `registerPackage` in this file. After that is removed, + * this likely can be removed, too. + * @param {ReactionNodeApp} app App instance + * @return {undefined} + */ + async onAppInstanceCreated(app) { + this.reactionNodeApp = app; + if (this.whenAppInstanceReadyCallbacks) { + for (const callback of this.whenAppInstanceReadyCallbacks) { + await callback(this.reactionNodeApp); // eslint-disable-line no-await-in-loop } + this.whenAppInstanceReadyCallbacks = []; } + }, - if (packageInfo.mutations) { - merge(mutations, packageInfo.mutations); - } - - if (packageInfo.queries) { - merge(queries, packageInfo.queries); - } - - if (packageInfo.functionsByType) { - Object.keys(packageInfo.functionsByType).forEach((type) => { - if (!Array.isArray(functionsByType[type])) { - functionsByType[type] = []; - } - functionsByType[type].push(...packageInfo.functionsByType[type]); - }); + /** + * @summary This is used only for the old `registerPackage` in this file. After that is removed, + * this likely can be removed, too. + * @param {Function} callback Function to call after `this.reactionNodeApp` is set, which might be immediately + * @return {undefined} + */ + whenAppInstanceReady(callback) { + if (this.reactionNodeApp) { + callback(this.reactionNodeApp); + } else { + if (!this.whenAppInstanceReadyCallbacks) this.whenAppInstanceReadyCallbacks = []; + this.whenAppInstanceReadyCallbacks.push(callback); } + }, - if (packageInfo.catalog) { - const { publishedProductFields, publishedProductVariantFields } = packageInfo.catalog; - if (Array.isArray(publishedProductFields)) { - customPublishedProductFields.push(...publishedProductFields); - } - if (Array.isArray(publishedProductVariantFields)) { - customPublishedProductVariantFields.push(...publishedProductVariantFields); - } - } + /** + * @deprecated Use `app.registerPlugin` pattern instead. See the simple-pricing plugin. + * @param {Object} packageInfo Plugin options + * @return {Object} Plugin options + */ + registerPackage(packageInfo) { + this.whenAppInstanceReady((app) => app.registerPlugin(packageInfo)); // Save the package info this.Packages[packageInfo.name] = packageInfo; - const registeredPackage = this.Packages[packageInfo.name]; - return registeredPackage; + return this.Packages[packageInfo.name]; }, + defaultCustomerRoles: ["guest", "account/profile", "product", "tag", "index", "cart/completed"], defaultVisitorRoles: ["anonymous", "guest", "product", "tag", "index", "cart/completed"], createGroups, @@ -244,7 +224,7 @@ export default { * @return {array} Array of shopIds that the user has at least one of the given set of roles for */ getShopsWithRoles(roles, userId = getUserId()) { - // Owner permission for a shop superceeds grantable permissions, so we always check for owner permissions as well + // Owner permission for a shop supercedes grantable permissions, so we always check for owner permissions as well roles.push("owner"); // Reducer that returns a unique list of shopIds that results from calling getGroupsForUser for each role @@ -748,10 +728,10 @@ export default { * @method * @memberof Core * @summary save user preferences in the Accounts collection - * @param {String} packageName - * @param {String} preference - * @param {String} value - * @param {String} userId + * @param {String} packageName Package name + * @param {String} preference Preference key + * @param {String} value Preference value + * @param {String} userId User ID * @return {Number} setPreferenceResult */ setUserPreferences(packageName, preference, value, userId) { @@ -775,9 +755,7 @@ export default { */ insertPackagesForShop(shopId) { const layouts = []; - if (!shopId) { - return []; - } + if (!shopId) return; // Check to see what packages should be enabled const shop = Shops.findOne({ _id: shopId }); @@ -894,7 +872,7 @@ export default { _.each(this.Packages, (config, pkgName) => Shops.find().forEach((shop) => { const shopId = shop._id; - if (!shopId) return []; + if (!shopId) return; // existing registry will be upserted with changes, perhaps we should add: this.assignOwnerRoles(shopId, pkgName, config.registry); @@ -1005,14 +983,14 @@ export default { const col = Collections[collection]; if (!col) { Logger.warn(errMsg); - // Return false so we don't pass a check that uses a non-existant schema + // Return false so we don't pass a check that uses a non-existent schema return false; } const schema = col.simpleSchema(selector); if (!schema) { Logger.warn(errMsg); - // Return false so we don't pass a check that uses a non-existant schema + // Return false so we don't pass a check that uses a non-existent schema return false; } diff --git a/imports/plugins/core/core/server/fixtures/cart.js b/imports/plugins/core/core/server/fixtures/cart.js index 006a8912e4c..fb2a7af569e 100755 --- a/imports/plugins/core/core/server/fixtures/cart.js +++ b/imports/plugins/core/core/server/fixtures/cart.js @@ -39,6 +39,7 @@ export function getCartItem(options = {}) { }).fetch(); const selectedOption = Random.choice(childVariants); const quantity = _.random(1, selectedOption.inventoryInStock); + const price = _.random(1, 100); const defaults = { _id: Random.id(), addedAt: new Date(), @@ -46,11 +47,11 @@ export function getCartItem(options = {}) { isTaxable: false, optionTitle: selectedOption.optionTitle, price: { - amount: selectedOption.price, + amount: price, currencyCode: "USD" }, priceWhenAdded: { - amount: selectedOption.price, + amount: price, currencyCode: "USD" }, productId: product._id, @@ -59,7 +60,7 @@ export function getCartItem(options = {}) { quantity, shopId: options.shopId || getShop()._id, subtotal: { - amount: selectedOption.price * quantity, + amount: price * quantity, currencyCode: "USD" }, title: product.title, diff --git a/imports/plugins/core/core/server/no-meteor/pluginRegistration.js b/imports/plugins/core/core/server/no-meteor/pluginRegistration.js deleted file mode 100644 index c219634c45f..00000000000 --- a/imports/plugins/core/core/server/no-meteor/pluginRegistration.js +++ /dev/null @@ -1,7 +0,0 @@ -export const customPublishedProductFields = []; -export const customPublishedProductVariantFields = []; -export const functionsByType = {}; -export const mutations = {}; -export const queries = {}; -export const resolvers = {}; -export const schemas = []; diff --git a/imports/plugins/core/core/server/no-meteor/resolvers/Shop/defaultNavigationTree.js b/imports/plugins/core/core/server/no-meteor/resolvers/Shop/defaultNavigationTree.js index 36b3356c662..4cbcfea6e34 100644 --- a/imports/plugins/core/core/server/no-meteor/resolvers/Shop/defaultNavigationTree.js +++ b/imports/plugins/core/core/server/no-meteor/resolvers/Shop/defaultNavigationTree.js @@ -1,19 +1,27 @@ /** - * @name "Shop.defaultNavigationTree" + * @name Shop/defaultNavigationTree * @method * @memberof Shop/GraphQL * @summary Returns the default navigation tree for a shop * @param {Object} shop The current shop - * @param {Objec} args Unused + * @param {Object} args - An object of all arguments that were sent by the previous resolver + * @param {String} args.id The ID of the navigation tree + * @param {String} args.language The language to load items in + * @param {Boolean} args.shouldIncludeSecondary Include secondary navigation items alongside primary items * @param {Object} context An object containing the per-request state * @return {Promise} Promise that resolves to a navigation tree document */ export default async function defaultNavigationTree(shop, args, context) { - const { defaultNavigationTreeId } = shop; + const { defaultNavigationTreeId: navigationTreeId } = shop; + const { language, shouldIncludeSecondary } = args; - if (!defaultNavigationTreeId) { - return; + if (!navigationTreeId) { + return null; } - return context.queries.navigationTreeById(context, args.language, defaultNavigationTreeId); + return context.queries.navigationTreeById(context, { + language, + navigationTreeId, + shouldIncludeSecondary + }); } diff --git a/imports/plugins/core/core/server/no-meteor/schemas/shop.graphql b/imports/plugins/core/core/server/no-meteor/schemas/shop.graphql index f734042a366..1003ed635c9 100644 --- a/imports/plugins/core/core/server/no-meteor/schemas/shop.graphql +++ b/imports/plugins/core/core/server/no-meteor/schemas/shop.graphql @@ -19,7 +19,7 @@ type Shop implements Node { currency: Currency "The default navigation tree for this shop" - defaultNavigationTree(language: String!): NavigationTree + defaultNavigationTree(language: String!, shouldIncludeSecondary: Boolean = false): NavigationTree "The ID of the shop's default navigation tree" defaultNavigationTreeId: String diff --git a/imports/plugins/core/core/server/startup/index.js b/imports/plugins/core/core/server/startup/index.js index 0d53d95544b..cdafb96c614 100644 --- a/imports/plugins/core/core/server/startup/index.js +++ b/imports/plugins/core/core/server/startup/index.js @@ -51,7 +51,11 @@ export default function startup() { CollectionSecurity(); RateLimiters(); - startNodeApp() + startNodeApp({ + async onAppInstanceCreated(app) { + await Reaction.onAppInstanceCreated(app); + } + }) .then(() => { const endTime = Date.now(); Logger.info(`Reaction initialization finished: ${endTime - startTime}ms`); diff --git a/imports/plugins/core/core/server/startup/startNodeApp.js b/imports/plugins/core/core/server/startup/startNodeApp.js index 7881217703b..7c618d272d0 100644 --- a/imports/plugins/core/core/server/startup/startNodeApp.js +++ b/imports/plugins/core/core/server/startup/startNodeApp.js @@ -1,5 +1,4 @@ import url from "url"; -import { merge } from "lodash"; import Logger from "@reactioncommerce/logger"; import { execute, subscribe } from "graphql"; import { Accounts } from "meteor/accounts-base"; @@ -15,7 +14,6 @@ import coreMutations from "../no-meteor/mutations"; import coreQueries from "../no-meteor/queries"; import coreResolvers from "../no-meteor/resolvers"; import coreSchemas from "../no-meteor/schemas"; -import { functionsByType, mutations, queries, resolvers, schemas } from "../no-meteor/pluginRegistration"; import runMeteorMethodWithContext from "../util/runMeteorMethodWithContext"; // For Meteor app tests @@ -24,24 +22,13 @@ export const isAppStartupComplete = () => appStartupIsComplete; /** * @summary Starts the Reaction Node app within a Meteor server + * @param {Function} [onAppInstanceCreated] Function to call with `app` after it is created * @returns {undefined} */ -export default async function startNodeApp() { +export default async function startNodeApp({ onAppInstanceCreated }) { const { ROOT_URL, GRAPHQL_INTROSPECTION } = process.env; const mongodb = MongoInternals.NpmModules.mongodb.module; - // Adding core mutations this way because `core` is not a typical plugin and doesn't call registerPackage - // Note that coreMutations comes first so that plugin resolvers can overwrite core mutations if necessary - const finalMutations = merge({}, coreMutations, mutations); - - // Adding core queries this way because `core` is not a typical plugin and doesn't call registerPackage - // Note that coreQueries comes first so that plugin queries can overwrite core queries if necessary - const finalQueries = merge({}, coreQueries, queries); - - // Adding core resolvers this way because `core` is not a typical plugin and doesn't call registerPackage - // Note that coreResolvers comes first so that plugin resolvers can overwrite core resolvers if necessary - const finalResolvers = merge({}, coreResolvers, resolvers); - const app = new ReactionNodeApp({ addCallMeteorMethod(context) { context.callMeteorMethod = (name, ...args) => runMeteorMethodWithContext(context, name, args); @@ -53,20 +40,23 @@ export default async function startNodeApp() { createUser(options) { return Accounts.createUser(options); }, - mutations: finalMutations, - queries: finalQueries, + queries: coreQueries, + mutations: coreMutations, rootUrl: ROOT_URL }, - functionsByType, graphQL: { graphiql: Meteor.isDevelopment || GRAPHQL_INTROSPECTION === "true", - resolvers: finalResolvers, - schemas: [...coreSchemas, ...schemas] + resolvers: coreResolvers, + schemas: coreSchemas }, httpServer: WebApp.httpServer, mongodb }); + if (onAppInstanceCreated) await onAppInstanceCreated(app); + + app.initServer(); + const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; app.setMongoDatabase(db); diff --git a/imports/plugins/core/graphql/server/no-meteor/xforms/cart.js b/imports/plugins/core/graphql/server/no-meteor/xforms/cart.js index b6ab16c9ff4..c899e010d2d 100644 --- a/imports/plugins/core/graphql/server/no-meteor/xforms/cart.js +++ b/imports/plugins/core/graphql/server/no-meteor/xforms/cart.js @@ -1,6 +1,5 @@ import { namespaces } from "@reactioncommerce/reaction-graphql-utils"; import ReactionError from "@reactioncommerce/reaction-error"; -import findVariantInCatalogProduct from "/imports/plugins/core/catalog/server/no-meteor/utils/findVariantInCatalogProduct"; import { xformCatalogProductMedia } from "./catalogProduct"; import { xformRateToRateObject } from "./core"; import { assocInternalId, assocOpaqueId, decodeOpaqueIdForNamespace, encodeOpaqueId } from "./id"; @@ -52,7 +51,7 @@ async function xformCartItem(context, catalogItems, products, cartItem) { const catalogProduct = catalogItem.product; - const { variant } = findVariantInCatalogProduct(catalogProduct, variantId); + const { variant } = context.queries.findVariantInCatalogProduct(catalogProduct, variantId); if (!variant) { throw new ReactionError("invalid-param", `Product with ID ${productId} has no variant with ID ${variantId}`); } diff --git a/imports/plugins/core/graphql/server/no-meteor/xforms/currency.js b/imports/plugins/core/graphql/server/no-meteor/xforms/currency.js index a59dfcae81a..5e1dccdd4a6 100644 --- a/imports/plugins/core/graphql/server/no-meteor/xforms/currency.js +++ b/imports/plugins/core/graphql/server/no-meteor/xforms/currency.js @@ -1,10 +1,7 @@ -import { toFixed } from "accounting-js"; import { assoc, compose, map, toPairs } from "ramda"; import ReactionError from "@reactioncommerce/reaction-error"; -import Logger from "@reactioncommerce/logger"; import { namespaces } from "@reactioncommerce/reaction-graphql-utils"; import CurrencyDefinitions from "/imports/utils/CurrencyDefinitions"; -import getDisplayPrice from "/imports/plugins/core/catalog/server/no-meteor/utils/getDisplayPrice"; import { assocInternalId, assocOpaqueId, decodeOpaqueIdForNamespace, encodeOpaqueId } from "./id"; export const assocCurrencyInternalId = assocInternalId(namespaces.Currency); @@ -48,56 +45,3 @@ export async function getXformedCurrencyByCode(code) { if (!entry) throw new ReactionError("invalid", `No currency definition found for ${code}`); return xformCurrencyEntry([code, entry]); } - -/** - * @name xformCurrencyExchangePricing - * @method - * @memberof GraphQL/Transforms - * @summary Converts price to the supplied currency and adds currencyExchangePricing to result - * @param {Object} pricing Original pricing object - * @param {String} currencyCode Code of currency to convert prices to - * @param {Object} context Object containing per-request state - * @returns {Object} New pricing object with converted prices - */ -export async function xformCurrencyExchangePricing(pricing, currencyCode, context) { - const shop = await context.queries.primaryShop(context); - - if (!currencyCode) { - currencyCode = shop.currency; // eslint-disable-line no-param-reassign - } - - const currencyInfo = shop.currencies[currencyCode]; - const { rate } = currencyInfo; - - // Stop processing if we don't have a valid currency exchange rate. - // rate may be undefined if Open Exchange Rates or an equivalent service is not configured properly. - if (typeof rate !== "number") { - Logger.debug("Currency exchange rates are not available. Exchange rate fetching may not be configured."); - return null; - } - - const { compareAtPrice, price, minPrice, maxPrice } = pricing; - const priceConverted = price && Number(toFixed(price * rate, 2)); - const minPriceConverted = minPrice && Number(toFixed(minPrice * rate, 2)); - const maxPriceConverted = maxPrice && Number(toFixed(maxPrice * rate, 2)); - const displayPrice = getDisplayPrice(minPriceConverted, maxPriceConverted, currencyInfo); - let compareAtPriceConverted = null; - - if (typeof compareAtPrice === "number" && compareAtPrice > 0) { - compareAtPriceConverted = { - amount: Number(toFixed(compareAtPrice * rate, 2)), - currencyCode - }; - } - - return { - compareAtPrice: compareAtPriceConverted, - displayPrice, - price: priceConverted, - minPrice: minPriceConverted, - maxPrice: maxPriceConverted, - currency: { - code: currencyCode - } - }; -} diff --git a/imports/plugins/core/graphql/server/no-meteor/xforms/currency.test.js b/imports/plugins/core/graphql/server/no-meteor/xforms/currency.test.js index d49393893e0..9dad79610a0 100644 --- a/imports/plugins/core/graphql/server/no-meteor/xforms/currency.test.js +++ b/imports/plugins/core/graphql/server/no-meteor/xforms/currency.test.js @@ -1,4 +1,4 @@ -import { xformLegacyCurrencies, xformCurrencyExchangePricing } from "./currency"; +import { xformLegacyCurrencies } from "./currency"; const input = { USD: { @@ -54,60 +54,6 @@ const expected = [ } ]; -const testShop = { - currency: "USD", - currencies: { - EUR: { - enabled: true, - format: "%v %s", - symbol: "€", - decimal: ",", - thousand: ".", - rate: 0.856467 - }, - EUR_NO_RATE: { - enabled: true, - format: "%v %s", - symbol: "€", - decimal: ",", - thousand: "." - } - } -}; - -const testContext = { - queries: { - primaryShop() { - return testShop; - } - } -}; - -const minMaxPricingInput = { - displayPrice: "$12.99 - $19.99", - maxPrice: 19.99, - minPrice: 12.99, - price: null, - currencyCode: "USD" -}; - -const minMaxPricingOutput = { - compareAtPrice: null, - displayPrice: "11,13 € - 17,12 €", - price: null, - minPrice: 11.13, - maxPrice: 17.12, - currency: { code: "EUR" } -}; - test("xformLegacyCurrencies converts legacy currency object to an array", () => { expect(xformLegacyCurrencies(input)).toEqual(expected); }); - -test("xformCurrencyExchangePricing converts min-max pricing object correctly", async () => { - expect(await xformCurrencyExchangePricing(minMaxPricingInput, "EUR", testContext)).toEqual(minMaxPricingOutput); -}); - -test("xformCurrencyExchangePricing converts min-max pricing object correctly", async () => { - expect(await xformCurrencyExchangePricing(minMaxPricingInput, "EUR_NO_RATE", testContext)).toEqual(null); -}); diff --git a/imports/plugins/core/graphql/server/no-meteor/xforms/order.js b/imports/plugins/core/graphql/server/no-meteor/xforms/order.js index 62df9a83f62..4519f14f2b7 100644 --- a/imports/plugins/core/graphql/server/no-meteor/xforms/order.js +++ b/imports/plugins/core/graphql/server/no-meteor/xforms/order.js @@ -1,6 +1,5 @@ import ReactionError from "@reactioncommerce/reaction-error"; import { namespaces } from "@reactioncommerce/reaction-graphql-utils"; -import findVariantInCatalogProduct from "/imports/plugins/core/catalog/server/no-meteor/utils/findVariantInCatalogProduct"; import { xformCatalogProductMedia } from "./catalogProduct"; import { assocInternalId, assocOpaqueId, decodeOpaqueIdForNamespace, encodeOpaqueId } from "./id"; @@ -104,7 +103,7 @@ async function xformOrderItem(context, item, catalogItems) { const catalogProduct = catalogItem.product; - const { variant } = findVariantInCatalogProduct(catalogProduct, variantId); + const { variant } = context.queries.findVariantInCatalogProduct(catalogProduct, variantId); if (!variant) { throw new ReactionError("invalid-param", `Product with ID ${productId} has no variant with ID ${variantId}`); } diff --git a/imports/plugins/core/graphql/server/no-meteor/xforms/product.js b/imports/plugins/core/graphql/server/no-meteor/xforms/product.js index d796a770843..9e49610ba96 100644 --- a/imports/plugins/core/graphql/server/no-meteor/xforms/product.js +++ b/imports/plugins/core/graphql/server/no-meteor/xforms/product.js @@ -1,4 +1,3 @@ -import { assoc, compose, map, toPairs } from "ramda"; import { namespaces } from "@reactioncommerce/reaction-graphql-utils"; import { assocInternalId, assocOpaqueId, decodeOpaqueIdForNamespace, encodeOpaqueId } from "./id"; @@ -6,10 +5,3 @@ export const assocProductInternalId = assocInternalId(namespaces.Product); export const assocProductOpaqueId = assocOpaqueId(namespaces.Product); export const decodeProductOpaqueId = decodeOpaqueIdForNamespace(namespaces.Product); export const encodeProductOpaqueId = encodeOpaqueId(namespaces.Product); - -// add `currencyCode` keys to each pricing info object -const xformPricingEntry = ([k, v]) => compose(assoc("currencyCode", k))(v); - -// map over all provided pricing info, provided in the format stored in our Catalog collection, -// and convert them to an array -export const xformPricingArray = compose(map(xformPricingEntry), toPairs); diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/getProductInventoryAvailabletoSellQuantity.test.js b/imports/plugins/core/inventory/server/no-meteor/utils/getProductInventoryAvailabletoSellQuantity.test.js index b05749c3853..3d4dee8a33e 100644 --- a/imports/plugins/core/inventory/server/no-meteor/utils/getProductInventoryAvailabletoSellQuantity.test.js +++ b/imports/plugins/core/inventory/server/no-meteor/utils/getProductInventoryAvailabletoSellQuantity.test.js @@ -43,7 +43,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Untitled Option", originCountry: "US", - price: 0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -83,7 +82,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -126,7 +124,6 @@ const mockVariantsWithUndefinedInventory = [ minOrderQuantity: 0, optionTitle: "Untitled Option", originCountry: "US", - price: 0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -166,7 +163,6 @@ const mockVariantsWithUndefinedInventory = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -202,7 +198,6 @@ const mockVariantsWithUndefinedInventory = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.test.js b/imports/plugins/core/inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.test.js index 2483444a4f0..9aa0620dd46 100644 --- a/imports/plugins/core/inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.test.js +++ b/imports/plugins/core/inventory/server/no-meteor/utils/getProductInventoryInStockQuantity.test.js @@ -43,7 +43,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Untitled Option", originCountry: "US", - price: 0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -83,7 +82,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -126,7 +124,6 @@ const mockVariantsWithUndefinedInventory = [ minOrderQuantity: 0, optionTitle: "Untitled Option", originCountry: "US", - price: 0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -166,7 +163,6 @@ const mockVariantsWithUndefinedInventory = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -202,7 +198,6 @@ const mockVariantsWithUndefinedInventory = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.test.js b/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.test.js index 42e51870ba8..4baaba244e1 100644 --- a/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.test.js +++ b/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryAvailableToSellQuantity.test.js @@ -43,7 +43,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Untitled Option", originCountry: "US", - price: 0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -83,7 +82,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -126,7 +124,6 @@ const mockVariantsWithUndefinedInventory = [ minOrderQuantity: 0, optionTitle: "Untitled Option", originCountry: "US", - price: 0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -166,7 +163,6 @@ const mockVariantsWithUndefinedInventory = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -202,7 +198,6 @@ const mockVariantsWithUndefinedInventory = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.test.js b/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.test.js index a2dc23d2adc..14abf2d8cdb 100644 --- a/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.test.js +++ b/imports/plugins/core/inventory/server/no-meteor/utils/getVariantInventoryInStockQuantity.test.js @@ -44,7 +44,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Untitled Option", originCountry: "US", - price: 0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -84,7 +83,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -127,7 +125,6 @@ const mockVariantsNoInventory = [ minOrderQuantity: 0, optionTitle: "Untitled Option", originCountry: "US", - price: 0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -167,7 +164,6 @@ const mockVariantsNoInventory = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -210,7 +206,6 @@ const mockVariantsWithUndefinedInventory = [ minOrderQuantity: 0, optionTitle: "Untitled Option", originCountry: "US", - price: 0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -250,7 +245,6 @@ const mockVariantsWithUndefinedInventory = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -286,7 +280,6 @@ const mockVariantsWithUndefinedInventory = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", diff --git a/imports/plugins/core/inventory/server/no-meteor/utils/updateCatalogProductInventoryStatus.test.js b/imports/plugins/core/inventory/server/no-meteor/utils/updateCatalogProductInventoryStatus.test.js index 63f514c218c..41fd3f8b5ca 100644 --- a/imports/plugins/core/inventory/server/no-meteor/utils/updateCatalogProductInventoryStatus.test.js +++ b/imports/plugins/core/inventory/server/no-meteor/utils/updateCatalogProductInventoryStatus.test.js @@ -50,7 +50,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Untitled Option", originCountry: "US", - price: 0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -90,7 +89,6 @@ const mockVariants = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 992.0, shopId: internalShopId, sku: "sku", taxCode: "0000", @@ -141,11 +139,6 @@ const mockProduct = { weight: 7.77 }, pinterestMsg: "pinterestMessage", - price: { - max: 5.99, - min: 2.99, - range: "2.99 - 5.99" - }, media: [ { metadata: { diff --git a/imports/plugins/core/layout/client/components/coreLayout.js b/imports/plugins/core/layout/client/components/coreLayout.js index 07586e1952a..bc7432c930a 100644 --- a/imports/plugins/core/layout/client/components/coreLayout.js +++ b/imports/plugins/core/layout/client/components/coreLayout.js @@ -49,6 +49,13 @@ function CoreLayout({ classes, location }) { // If the user is logged in, which makes them no longer anonymous // But they aren't an admin, then give them a logout button. if (!Reaction.hasPermission(["anonymous"])) { + // If the user is logged in but not a admin redirect to the storefront. + if (!Reaction.hasAdminAccess()) { + const { storefrontUrls } = Reaction.getCurrentShop(); + window.location.href = `${storefrontUrls.storefrontHomeUrl}`; + return null; + } + content = (
); } diff --git a/imports/plugins/core/navigation/client/components/NavigationItemForm.js b/imports/plugins/core/navigation/client/components/NavigationItemForm.js index 2d383bdf4a0..a199160dfe9 100644 --- a/imports/plugins/core/navigation/client/components/NavigationItemForm.js +++ b/imports/plugins/core/navigation/client/components/NavigationItemForm.js @@ -6,6 +6,7 @@ import Grid from "@material-ui/core/Grid"; import { Form } from "reacto-form"; import withStyles from "@material-ui/core/styles/withStyles"; import Button from "@material-ui/core/Button"; +import Divider from "@material-ui/core/Divider"; import IconButton from "@material-ui/core/IconButton"; import CloseIcon from "mdi-material-ui/Close"; import Checkbox from "@reactioncommerce/components/Checkbox/v1"; @@ -14,6 +15,7 @@ import Field from "@reactioncommerce/components/Field/v1"; import TextInput from "@reactioncommerce/components/TextInput/v1"; import { i18next } from "/client/api"; import ConfirmDialog from "/imports/client/ui/components/ConfirmDialog"; +import { changeNodeAtPath } from "react-sortable-tree"; const styles = (theme) => ({ closeButtonContainer: { @@ -49,6 +51,8 @@ class NavigationItemForm extends Component { mode: PropTypes.oneOf(["create", "edit"]), navigationItem: PropTypes.object, onCloseForm: PropTypes.func, + onSetSortableNavigationTree: PropTypes.func, + sortableTreeNode: PropTypes.object, updateNavigationItem: PropTypes.func } @@ -57,8 +61,8 @@ class NavigationItemForm extends Component { handleFormValidate = (doc) => navigationItemValidator(navigationItemFormSchema.clean(doc)); handleFormSubmit = (input) => { - const { createNavigationItem, mode, navigationItem, onCloseForm, updateNavigationItem } = this.props; - const { name, url, isUrlRelative, shouldOpenInNewWindow, classNames } = input; + const { createNavigationItem, mode, navigationItem, onCloseForm, updateNavigationItem, sortableTreeNode, onSetSortableNavigationTree } = this.props; + const { name, url, isUrlRelative, isVisible, isPrivate, isSecondary, shouldOpenInNewWindow, classNames } = input; const navigationItemUpdate = { draftData: { @@ -91,6 +95,21 @@ class NavigationItemForm extends Component { } }); } + + if (navigationItem && navigationItem.isInNavigationTree) { + const newSortableNavigationTree = changeNodeAtPath({ + ...sortableTreeNode, + newNode: { + ...sortableTreeNode.node, + isVisible, + isPrivate, + isSecondary + } + }); + + onSetSortableNavigationTree(newSortableNavigationTree); + } + return onCloseForm(); } @@ -170,6 +189,23 @@ class NavigationItemForm extends Component { + {navigationItem && navigationItem.isInNavigationTree && + + + + + + + + + + + + + + + + } diff --git a/imports/plugins/core/navigation/client/components/NavigationTreeContainer.js b/imports/plugins/core/navigation/client/components/NavigationTreeContainer.js index 10f62506aa1..004d3572807 100644 --- a/imports/plugins/core/navigation/client/components/NavigationTreeContainer.js +++ b/imports/plugins/core/navigation/client/components/NavigationTreeContainer.js @@ -41,7 +41,16 @@ class NavigationTreeContainer extends Component { return { buttons: [ - { onClickUpdateNavigationItem(node.navigationItem); }}> + { + onClickUpdateNavigationItem(node.navigationItem, { + getNodeKey: this.getNodeKey, + node, + path, + treeData: sortableNavigationTree + }); + }} + > , ({ + badge: { + backgroundColor: theme.palette.colors.black05, + color: theme.palette.colors.black65, + padding: "0 8px", + height: 20, + borderRadius: 10, + border: `1px solid ${theme.palette.colors.black10}`, + fontSize: "0.73rem", + display: "inline-block", + marginLeft: theme.spacing.unit + }, cardContent: { display: "flex", alignItems: "center", @@ -263,6 +274,9 @@ class SortableNodeContentRenderer extends Component {
{typeof nodeTitle === "function" ? nodeTitle({ node, path, treeIndex }) : nodeTitle} + {node.isVisible === false && {"Hide from storefront"}} + {node.isPrivate && {"Admin only"}} + {node.isSecondary && {"Secondary nav only"}} diff --git a/imports/plugins/core/navigation/client/hocs/fragments.js b/imports/plugins/core/navigation/client/hocs/fragments.js index 1104707ed3b..c170138f1fd 100644 --- a/imports/plugins/core/navigation/client/hocs/fragments.js +++ b/imports/plugins/core/navigation/client/hocs/fragments.js @@ -21,6 +21,9 @@ export const navigationItemFragment = gql` export const navigationTreeItemFragment = gql` fragment NavigationTreeItem on NavigationTreeItem { expanded + isVisible + isPrivate + isSecondary navigationItem { ...NavigationItem } diff --git a/imports/plugins/core/navigation/client/hocs/withNavigationUIStore.js b/imports/plugins/core/navigation/client/hocs/withNavigationUIStore.js index f5722b114e1..ce6a6284b05 100644 --- a/imports/plugins/core/navigation/client/hocs/withNavigationUIStore.js +++ b/imports/plugins/core/navigation/client/hocs/withNavigationUIStore.js @@ -43,6 +43,9 @@ export default (Component) => ( return navigationTree.map((node) => { const newNode = {}; newNode.id = node.navigationItem._id; + newNode.isVisible = typeof node.isVisible === "boolean" ? node.isVisible : true; + newNode.isPrivate = typeof node.isPrivate === "boolean" ? node.isPrivate : false; + newNode.isSecondary = typeof node.isSecondary === "boolean" ? node.isSecondary : false; newNode.title = this.getNavigationItemTitle(node.navigationItem).value; newNode.expanded = node.navigationItem.expanded; newNode.subtitle = node.navigationItem.draftData.url; diff --git a/imports/plugins/core/navigation/client/hocs/withUpdateNavigationTree.js b/imports/plugins/core/navigation/client/hocs/withUpdateNavigationTree.js index 482360c3717..5b7b2ccefe3 100644 --- a/imports/plugins/core/navigation/client/hocs/withUpdateNavigationTree.js +++ b/imports/plugins/core/navigation/client/hocs/withUpdateNavigationTree.js @@ -35,6 +35,9 @@ export default (Component) => ( const newNode = {}; newNode.navigationItemId = node.id; newNode.expanded = node.expanded; + newNode.isVisible = typeof node.isVisible === "boolean" ? node.isVisible : true; + newNode.isPrivate = typeof node.isPrivate === "boolean" ? node.isPrivate : false; + newNode.isSecondary = typeof node.isSecondary === "boolean" ? node.isSecondary : false; if (Array.isArray(node.children) && node.children.length) { newNode.items = this.sortableNavigationTreeToDraftItems(node.children); diff --git a/imports/plugins/core/navigation/server/i18n/en.json b/imports/plugins/core/navigation/server/i18n/en.json index 26b3f84236a..e70843c1d0b 100644 --- a/imports/plugins/core/navigation/server/i18n/en.json +++ b/imports/plugins/core/navigation/server/i18n/en.json @@ -19,7 +19,13 @@ "url": "URL", "isUrlRelative": "This URL is relative", "shouldOpenInNewWindow": "Open in a new tab", - "classNames": "CSS Class Names" + "classNames": "CSS Class Names", + "isVisible": "Show in storefront", + "isVisibleHelpText": "Make this navigation item available to the storefront for both customers and admins.", + "isPrivate": "Admin access only", + "isPrivateHelpText": "Show only to admins. Used for staging changes to the navigation without exposing it to customers. \"Show in storefront\" must be checked.", + "isSecondary": "Secondary nav only", + "isSecondaryHelpText": "Prevent this item from showing up as a main navigation item. This item may still be visible in some navigation bars or sidebars." } } } diff --git a/imports/plugins/core/navigation/server/no-meteor/mutations/updateNavigationTree.js b/imports/plugins/core/navigation/server/no-meteor/mutations/updateNavigationTree.js index fd9fe0f58b5..ec2a2d20693 100644 --- a/imports/plugins/core/navigation/server/no-meteor/mutations/updateNavigationTree.js +++ b/imports/plugins/core/navigation/server/no-meteor/mutations/updateNavigationTree.js @@ -1,6 +1,7 @@ import ReactionError from "@reactioncommerce/reaction-error"; import { NavigationTree as NavigationTreeSchema } from "/imports/collections/schemas"; import decodeNavigationTreeItemIds from "../util/decodeNavigationTreeItemIds"; +import setDefaultsForNavigationTreeItems from "../util/setDefaultsForNavigationTreeItems"; /** * @method updateNavigationTree @@ -14,8 +15,18 @@ export default async function updateNavigationTree(context, _id, navigationTree) const { collections, userHasPermission } = context; const { NavigationTrees } = collections; - NavigationTreeSchema.validate(navigationTree); - const { draftItems, name } = navigationTree; + // Set default values for navigation tree draft items before validation. + // isVisible, isPrivate, and isSecondary are optional in the GraphQL schema, + // but are required by SimpleSchema. This reduces the input payload size by not + // needing to include values that aren't explicitly set. + const navigationTreeData = { + ...navigationTree, + draftItems: setDefaultsForNavigationTreeItems(navigationTree.draftItems) + }; + + // Validate the navigation tree with the defaults set + NavigationTreeSchema.validate(navigationTreeData); + const { draftItems, name } = navigationTreeData; const shopId = await context.queries.primaryShopId(collections); diff --git a/imports/plugins/core/navigation/server/no-meteor/queries/navigationTreeById.js b/imports/plugins/core/navigation/server/no-meteor/queries/navigationTreeById.js index bd15d48581b..ae995ba224a 100644 --- a/imports/plugins/core/navigation/server/no-meteor/queries/navigationTreeById.js +++ b/imports/plugins/core/navigation/server/no-meteor/queries/navigationTreeById.js @@ -1,21 +1,38 @@ +import filterNavigationTreeItems from "../util/filterNavigationTreeItems"; + /** * @name navigationTreeById * @method * @memberof Navigation/NoMeteorQueries * @summary Query for loading a navigation tree by _id * @param {Object} context An object containing the per-request state - * @param {String} language Language to filter item content by - * @param {String} _id The _id of the navigation tree + * @param {Object} args Params to find and filter the navigation tree by + * @param {String} args.language Language to filter item content by + * @param {String} args.navigationTreeId Navigation tree id + * @param {Boolean} [args.shouldIncludeSecondary] Include secondary navigation items alongside primary items * @return {Promise} A MongoDB cursor for the proper query */ -export default async function navigationTreeById(context, language, _id) { - const { collections } = context; +export default async function navigationTreeById(context, { language, navigationTreeId, shouldIncludeSecondary = false } = {}) { + const { collections, userHasPermission } = context; const { NavigationTrees } = collections; - const navigationTree = await NavigationTrees.findOne({ _id }); + const navigationTree = await NavigationTrees.findOne({ _id: navigationTreeId }); if (navigationTree) { // Add language from args so that we can use it in items & draftItems resolvers navigationTree.language = language; + + const isAdmin = userHasPermission(["admin", "owner", "create-product"]); + + // Filter items based on visibility options and user permissions + navigationTree.items = filterNavigationTreeItems(navigationTree.items, { + isAdmin, + shouldIncludeSecondary + }); + + // Prevent non-admin users from getting draft items in results + if (!isAdmin) { + navigationTree.draftItems = null; + } } return navigationTree; diff --git a/imports/plugins/core/navigation/server/no-meteor/queries/navigationTreeById.test.js b/imports/plugins/core/navigation/server/no-meteor/queries/navigationTreeById.test.js index 54e1235f510..f687c0a2d4f 100644 --- a/imports/plugins/core/navigation/server/no-meteor/queries/navigationTreeById.test.js +++ b/imports/plugins/core/navigation/server/no-meteor/queries/navigationTreeById.test.js @@ -11,7 +11,10 @@ const mockNavigationTree = { test("calls NavigationTrees.findOne and returns a navigation tree", async () => { mockContext.collections.NavigationTrees.findOne.mockReturnValueOnce(mockNavigationTree); - const result = await navigationTreeByIdQuery(mockContext, "en", mockNavigationTreeId); + const result = await navigationTreeByIdQuery(mockContext, { + language: "en", + navigationTreeId: mockNavigationTreeId + }); expect(result).toBe(mockNavigationTree); expect(mockContext.collections.NavigationTrees.findOne).toHaveBeenCalledWith(query); }); diff --git a/imports/plugins/core/navigation/server/no-meteor/resolvers/Query/navigationTreeById.js b/imports/plugins/core/navigation/server/no-meteor/resolvers/Query/navigationTreeById.js index 0fe5440a8a4..16a12c323c5 100644 --- a/imports/plugins/core/navigation/server/no-meteor/resolvers/Query/navigationTreeById.js +++ b/imports/plugins/core/navigation/server/no-meteor/resolvers/Query/navigationTreeById.js @@ -1,15 +1,5 @@ -import SimpleSchema from "simpl-schema"; import { decodeNavigationTreeOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/navigationTree"; -const argsSchema = new SimpleSchema({ - id: { - type: String - }, - language: { - type: String - } -}); - /** * @name Query.navigationTreeById * @method @@ -19,15 +9,18 @@ const argsSchema = new SimpleSchema({ * @param {ConnectionArgs} args An object of all arguments that were sent by the client * @param {String} args.id The ID of the navigation tree * @param {String} args.language The language to load items in + * @param {Boolean} args.shouldIncludeSecondary Include secondary navigation items alongside primary items * @param {Object} context An object containing the per-request state * @return {Promise} A NavigationTree object */ export default async function navigationTreeById(_, args, context) { - const { id, language } = args; - - argsSchema.validate({ id, language }); + const { id, language, shouldIncludeSecondary } = args; - const decodedId = decodeNavigationTreeOpaqueId(id); + const navigationTreeId = decodeNavigationTreeOpaqueId(id); - return context.queries.navigationTreeById(context, language, decodedId); + return context.queries.navigationTreeById(context, { + language, + navigationTreeId, + shouldIncludeSecondary + }); } diff --git a/imports/plugins/core/navigation/server/no-meteor/resolvers/Query/navigationTreeById.test.js b/imports/plugins/core/navigation/server/no-meteor/resolvers/Query/navigationTreeById.test.js index e6bf6b34df4..16244a83180 100644 --- a/imports/plugins/core/navigation/server/no-meteor/resolvers/Query/navigationTreeById.test.js +++ b/imports/plugins/core/navigation/server/no-meteor/resolvers/Query/navigationTreeById.test.js @@ -42,13 +42,17 @@ test("calls queries.navigationTreeById and returns a navigation tree", async () .mockName("queries.navigationTreeById") .mockReturnValueOnce(Promise.resolve(mockNavigationTree)); - const result = await navigationTreeByIdResolver({}, { id: opaqueNavigationTreeId, language: "en" }, { + const args = { id: opaqueNavigationTreeId, language: "en" }; + const result = await navigationTreeByIdResolver({}, args, { queries: { navigationTreeById } }); expect(result).toEqual(mockNavigationTree); expect(navigationTreeById).toHaveBeenCalled(); - expect(navigationTreeById.mock.calls[0][1]).toEqual("en"); - expect(navigationTreeById.mock.calls[0][2]).toEqual(decodedNavigationTreeId); + expect(navigationTreeById.mock.calls[0][1]).toEqual({ + language: "en", + navigationTreeId: decodedNavigationTreeId, + shouldIncludeSecondary: undefined + }); }); diff --git a/imports/plugins/core/navigation/server/no-meteor/schemas/schema.graphql b/imports/plugins/core/navigation/server/no-meteor/schemas/schema.graphql index ab469191d3b..bb3c17685da 100644 --- a/imports/plugins/core/navigation/server/no-meteor/schemas/schema.graphql +++ b/imports/plugins/core/navigation/server/no-meteor/schemas/schema.graphql @@ -1,6 +1,15 @@ extend type Query { "Returns a navigation tree by its ID in the specified language" - navigationTreeById(id: ID!, language: String!): NavigationTree + navigationTreeById( + "The ID of the navigation tee" + id: ID!, + + "Navigation language" + language: String!, + + "Set to true if you want to include secondary navigation items along with the primary items" + shouldIncludeSecondary: Boolean = false + ): NavigationTree "Returns the navigation items for a shop" navigationItemsByShopId( @@ -141,6 +150,15 @@ type NavigationTreeItem { "Whether the navigation item should display its children" expanded: Boolean + "Whether the navigation item should be hidden from customers" + isPrivate: Boolean + + "Whether the navigaton item is a secondary navigation item" + isSecondary: Boolean + + "Whether the navigation ttem should shown in query results for customers and admins" + isVisible: Boolean + "The child navigation items" items: [NavigationTreeItem] @@ -266,6 +284,15 @@ input NavigationTreeItemInput { "Whether the navigation item should display its children" expanded: Boolean + "Whether the navigation item should be hidden from customers" + isPrivate: Boolean + + "Whether the navigaton item is a secondary navigation item" + isSecondary: Boolean + + "Whether the navigation ttem should shown in query results for customers and admins" + isVisible: Boolean + "The child navigation items" items: [NavigationTreeItemInput] diff --git a/imports/plugins/core/navigation/server/no-meteor/util/filterNavigationTreeItems.js b/imports/plugins/core/navigation/server/no-meteor/util/filterNavigationTreeItems.js new file mode 100644 index 00000000000..25c80ac2ad1 --- /dev/null +++ b/imports/plugins/core/navigation/server/no-meteor/util/filterNavigationTreeItems.js @@ -0,0 +1,39 @@ +/** + * @name filterNavigationTreeItems + * @summary Recursively navigation and filters out navigation items based on options provided + * @param {Array} items Navigation tree items + * @param {Object} options Filter options + * @param {Boolean} [options.isAdmin] Filter by admin rights + * @param {Boolean} [options.shouldIncludeSecondary] Include secondary items + * @return {Array} Navigation tree items + */ +export default function filterNavigationTreeItems(items = [], { isAdmin = false, shouldIncludeSecondary = false } = {}) { + return items.map((node) => { + const { isVisible, isPrivate, isSecondary } = node; + + // Hide items that are strictly not visible + if (isVisible === false) { + return null; + } + + // Hide private items unless requested + if (isPrivate && !isAdmin) { + return null; + } + + // Exclude secondary items unless requested + if (isSecondary && shouldIncludeSecondary !== true) { + return null; + } + + // Copy node before mutating + const newNode = { ...node }; + + // Check children recursively + if (Array.isArray(node.items) && node.items.length) { + newNode.items = filterNavigationTreeItems(node.items, { isAdmin, shouldIncludeSecondary }); + } + + return newNode; + }).filter((item) => item !== null); +} diff --git a/imports/plugins/core/navigation/server/no-meteor/util/filterNavigationTreeItems.test.js b/imports/plugins/core/navigation/server/no-meteor/util/filterNavigationTreeItems.test.js new file mode 100644 index 00000000000..4740e29b687 --- /dev/null +++ b/imports/plugins/core/navigation/server/no-meteor/util/filterNavigationTreeItems.test.js @@ -0,0 +1,71 @@ +import filterNavigationTreeItems from "./filterNavigationTreeItems"; + +const mockNavigationTreeItems = [{ + isVisible: true, + isPrivate: false, + isSecondary: true, + navigationItemId: 1000, + items: [{ + isVisible: true, + isPrivate: false, + isSecondary: false, + navigationItemId: 1001 + }, { + isVisible: true, + isPrivate: true, + isSecondary: false, + navigationItemId: 1002 + }, { + isVisible: false, + isPrivate: true, + isSecondary: true, + navigationItemId: 1003 + }] +}, { + isVisible: true, + isPrivate: false, + isSecondary: false, + navigationItemId: 2000 +}, { + isVisible: true, + isPrivate: true, + isSecondary: false, + navigationItemId: 3000 +}, { + isVisible: false, + isPrivate: false, + isSecondary: true, + navigationItemId: 4000 +}]; + +test("filters navigation tree excluding secondary items", async () => { + const results = filterNavigationTreeItems(mockNavigationTreeItems, { + isAdmin: false, + shouldIncludeSecondary: false + }); + + expect(results.length).toBe(1); + expect(results[0].navigationItemId).toBe(2000); +}); + +test("filters navigation tree including secondary items", async () => { + const results = filterNavigationTreeItems(mockNavigationTreeItems, { + isAdmin: false, + shouldIncludeSecondary: true + }); + + expect(results.length).toBe(2); + expect(results[0].navigationItemId).toBe(1000); + expect(results[0].items.length).toBe(1); +}); + +test("filters navigation tree including secondary and private items", async () => { + const results = filterNavigationTreeItems(mockNavigationTreeItems, { + isAdmin: true, + shouldIncludeSecondary: true + }); + + expect(results.length).toBe(3); + expect(results[0].navigationItemId).toBe(1000); + expect(results[0].items.length).toBe(2); +}); diff --git a/imports/plugins/core/navigation/server/no-meteor/util/setDefaultsForNavigationTreeItems.js b/imports/plugins/core/navigation/server/no-meteor/util/setDefaultsForNavigationTreeItems.js new file mode 100644 index 00000000000..2ba03d8d6b4 --- /dev/null +++ b/imports/plugins/core/navigation/server/no-meteor/util/setDefaultsForNavigationTreeItems.js @@ -0,0 +1,29 @@ +/** + * @name setDefaultsForNavigationTreeItems + * @summary Recursively sets any optional values in the navigation tree with sane defaults + * @param {Array} items Navigation tree items + * @return {Array} Navigation tree items + */ +export default function setDefaultsForNavigationTreeItems(items = []) { + return items.map((node) => { + const { isVisible, isPrivate, isSecondary } = node; + + // Set defaults for fields that are optional in the GraphQL schema + // but may be required by SimpleSchema for the database collection. + // This allows a user to send vastly less data down the wire, but still get sane + // defaults for various visibility options. + const newNode = { + ...node, + isPrivate: typeof isPrivate === "boolean" ? isPrivate : false, + isSecondary: typeof isSecondary === "boolean" ? isSecondary : false, + isVisible: typeof isVisible === "boolean" ? isVisible : true + }; + + // Check children recursively + if (Array.isArray(node.items) && node.items.length) { + newNode.items = setDefaultsForNavigationTreeItems(node.items); + } + + return newNode; + }); +} diff --git a/imports/plugins/core/navigation/server/no-meteor/util/setDefaultsForNavigationTreeItems.test.js b/imports/plugins/core/navigation/server/no-meteor/util/setDefaultsForNavigationTreeItems.test.js new file mode 100644 index 00000000000..e785e141beb --- /dev/null +++ b/imports/plugins/core/navigation/server/no-meteor/util/setDefaultsForNavigationTreeItems.test.js @@ -0,0 +1,46 @@ +import setDefaultsForNavigationTreeItems from "./setDefaultsForNavigationTreeItems"; + +const mockNavigationTreeItemsInput = [ + { + navigationItemId: "cmVhY3Rpb24vbmF2aWdhdGlvbkl0ZW06dWFYWGF3YzVveHk5ZVI0aFA=", + items: [ + { + isPrivate: true, + navigationItemId: "cmVhY3Rpb24vbmF2aWdhdGlvbkl0ZW06dEFLQVRRZXFvRDRBaDVnZzI=" + } + ] + }, + { + isSecondary: true, + isVisible: false, + navigationItemId: "cmVhY3Rpb24vbmF2aWdhdGlvbkl0ZW06S0VjbjZOdnJSdXp0bVBNcTg=" + } +]; + +const mockNavigationTreeItemsResult = [ + { + isPrivate: false, + isSecondary: false, + isVisible: true, + navigationItemId: "cmVhY3Rpb24vbmF2aWdhdGlvbkl0ZW06dWFYWGF3YzVveHk5ZVI0aFA=", + items: [ + { + isPrivate: true, + isSecondary: false, + isVisible: true, + navigationItemId: "cmVhY3Rpb24vbmF2aWdhdGlvbkl0ZW06dEFLQVRRZXFvRDRBaDVnZzI=" + } + ] + }, + { + isPrivate: false, + isSecondary: true, + isVisible: false, + navigationItemId: "cmVhY3Rpb24vbmF2aWdhdGlvbkl0ZW06S0VjbjZOdnJSdXp0bVBNcTg=" + } +]; + +test("filters navigation tree excluding secondary items", async () => { + const results = setDefaultsForNavigationTreeItems(mockNavigationTreeItemsInput); + expect(results).toEqual(mockNavigationTreeItemsResult); +}); diff --git a/imports/plugins/core/navigation/server/no-meteor/xforms/xformNavigationTreeItem.js b/imports/plugins/core/navigation/server/no-meteor/xforms/xformNavigationTreeItem.js index 705f3e3b8c6..1ff91755265 100644 --- a/imports/plugins/core/navigation/server/no-meteor/xforms/xformNavigationTreeItem.js +++ b/imports/plugins/core/navigation/server/no-meteor/xforms/xformNavigationTreeItem.js @@ -11,7 +11,7 @@ import getNavigationItemContentForLanguage from "../util/getNavigationItemConten export default async function xformNavigationTreeItem(context, language, item) { const { collections } = context; const { NavigationItems } = collections; - const { expanded, navigationItemId } = item; + const { expanded, isVisible, isPrivate, isSecondary, navigationItemId } = item; let { items = [] } = item; const navigationItem = await NavigationItems.findOne({ _id: navigationItemId }); @@ -34,6 +34,9 @@ export default async function xformNavigationTreeItem(context, language, item) { return { navigationItem, expanded, + isVisible: typeof isVisible === "boolean" ? isVisible : true, + isPrivate: typeof isVisible === "boolean" ? isPrivate : false, + isSecondary: typeof isVisible === "boolean" ? isSecondary : false, items }; } diff --git a/imports/plugins/core/orders/server/no-meteor/mutations/placeOrder.test.js b/imports/plugins/core/orders/server/no-meteor/mutations/placeOrder.test.js index a06c82b0ca5..5b4581584e3 100644 --- a/imports/plugins/core/orders/server/no-meteor/mutations/placeOrder.test.js +++ b/imports/plugins/core/orders/server/no-meteor/mutations/placeOrder.test.js @@ -24,10 +24,14 @@ test("places an anonymous $0 order with no cartId and no payments", async () => const catalogProduct = Factory.CatalogProduct.makeOne(); const catalogProductVariant = Factory.CatalogVariantSchema.makeOne(); - mockContext.queries.getCurrentCatalogPriceForProductConfiguration = jest.fn().mockName("getCurrentCatalogPriceForProductConfiguration"); - mockContext.queries.getCurrentCatalogPriceForProductConfiguration.mockReturnValueOnce({ + mockContext.queries.findProductAndVariant = jest.fn().mockName("findProductAndVariant"); + mockContext.queries.findProductAndVariant.mockReturnValueOnce({ catalogProduct, - catalogProductVariant, + variant: catalogProductVariant + }); + + mockContext.queries.getVariantPrice = jest.fn().mockName("getVariantPrice"); + mockContext.queries.getVariantPrice.mockReturnValueOnce({ price: 0 }); diff --git a/imports/plugins/core/orders/server/no-meteor/util/buildOrderItem.js b/imports/plugins/core/orders/server/no-meteor/util/buildOrderItem.js index 31e1831ee14..63e4fa52359 100644 --- a/imports/plugins/core/orders/server/no-meteor/util/buildOrderItem.js +++ b/imports/plugins/core/orders/server/no-meteor/util/buildOrderItem.js @@ -10,18 +10,29 @@ import ReactionError from "@reactioncommerce/reaction-error"; * @returns {Promise} An order item, matching the schema needed for insertion in the Orders collection */ export default async function buildOrderItem(context, { currencyCode, inputItem }) { + const { queries } = context; const { addedAt, price, - productConfiguration, + productConfiguration: { + productId, + productVariantId + }, quantity } = inputItem; const { catalogProduct: chosenProduct, - catalogProductVariant: chosenVariant, - price: finalPrice - } = await context.queries.getCurrentCatalogPriceForProductConfiguration(productConfiguration, currencyCode, context.collections); + variant: chosenVariant + } = await queries.findProductAndVariant(context, productId, productVariantId); + + const variantPriceInfo = await queries.getVariantPrice(context, chosenVariant, currencyCode); + const finalPrice = (variantPriceInfo || {}).price; + + // Handle null or undefined price returned. Don't allow sale. + if (!finalPrice && finalPrice !== 0) { + throw new ReactionError("invalid", `Unable to get current price for "${chosenVariant.title || chosenVariant._id}"`); + } if (finalPrice !== price) { throw new ReactionError("invalid", `Provided price for the "${chosenVariant.title}" item does not match current published price`); diff --git a/imports/plugins/core/orders/server/no-meteor/util/getDataForOrderEmail.test.js b/imports/plugins/core/orders/server/no-meteor/util/getDataForOrderEmail.test.js index 6aff903d5d1..15ecdc1a147 100644 --- a/imports/plugins/core/orders/server/no-meteor/util/getDataForOrderEmail.test.js +++ b/imports/plugins/core/orders/server/no-meteor/util/getDataForOrderEmail.test.js @@ -54,6 +54,12 @@ test("returns expected data structure", async () => { mockContext.collections.Shops.findOne.mockReturnValueOnce(mockShop); mockContext.collections.Catalog.toArray.mockReturnValueOnce([mockCatalogItem]); + mockContext.queries.findVariantInCatalogProduct = jest.fn().mockName("findVariantInCatalogProduct"); + mockContext.queries.findVariantInCatalogProduct.mockReturnValueOnce({ + catalogProduct: mockCatalogItem.product, + variant: mockCatalogItem.product.variants[0] + }); + const data = await getDataForOrderEmail(mockContext, { order: mockOrder }); expect(data).toEqual({ diff --git a/imports/plugins/core/router/client/theme/muiTheme.js b/imports/plugins/core/router/client/theme/muiTheme.js index ec4a0595778..3f975a42788 100644 --- a/imports/plugins/core/router/client/theme/muiTheme.js +++ b/imports/plugins/core/router/client/theme/muiTheme.js @@ -122,6 +122,15 @@ export const rawMuiTheme = { padding: `${defaultSpacingUnit}px ${defaultSpacingUnit * 2}px`, textTransform: "initial" }, + text: { + padding: `${defaultSpacingUnit}px ${defaultSpacingUnit * 2}px` + }, + outlined: { + // Removed 1px of padding from the top/bottom to account for the border + // which adds 1px to the top/bottom. This makes the button height even + // with the contained variant. + padding: `${defaultSpacingUnit - 1}px ${defaultSpacingUnit * 2}px` + }, outlinedPrimary: { border: `1px solid ${colorPrimaryMain}` }, diff --git a/imports/plugins/core/shipping/server/no-meteor/util/extendCommonOrder.js b/imports/plugins/core/shipping/server/no-meteor/util/extendCommonOrder.js index f04d0528e7f..8eed16f6f1b 100644 --- a/imports/plugins/core/shipping/server/no-meteor/util/extendCommonOrder.js +++ b/imports/plugins/core/shipping/server/no-meteor/util/extendCommonOrder.js @@ -1,5 +1,5 @@ import ReactionError from "@reactioncommerce/reaction-error"; -import { findCatalogProductsAndVariants, tagsByIds, mergeProductAndVariants } from "./helpers"; +import { tagsByIds, mergeProductAndVariants } from "./helpers"; /** * @name extendCommonOrder @@ -11,13 +11,13 @@ import { findCatalogProductsAndVariants, tagsByIds, mergeProductAndVariants } fr * @returns {Object|null} shipping restriction attributes for the provided fulfillment group */ export default async function extendCommonOrder(context, commonOrder) { - const { collections, getFunctionsOfType } = context; + const { collections, getFunctionsOfType, queries } = context; const { items: orderItems } = commonOrder; const products = []; // Products in the Catalog collection are the source of truth, therefore use them // as the source of data instead of what is coming from the client. - const catalogProductsAndVariants = await findCatalogProductsAndVariants(collections, orderItems); + const catalogProductsAndVariants = await queries.findCatalogProductsAndVariants(context, orderItems); const allProductsTags = await tagsByIds(collections, catalogProductsAndVariants); for (const orderLineItem of orderItems) { diff --git a/imports/plugins/core/shipping/server/no-meteor/util/helpers.js b/imports/plugins/core/shipping/server/no-meteor/util/helpers.js index f596ce5c00d..0fd5b73b0da 100644 --- a/imports/plugins/core/shipping/server/no-meteor/util/helpers.js +++ b/imports/plugins/core/shipping/server/no-meteor/util/helpers.js @@ -1,40 +1,5 @@ -import findVariantInCatalogProduct from "/imports/plugins/core/catalog/server/no-meteor/utils/findVariantInCatalogProduct"; import _ from "lodash"; -/** - * @name findCatalogProductsAndVariants - * @summary Returns products in the Catalog collection that correspond to the cart items provided. - * @param {Object} collections - The mongo collections - * @param {Array} orderLineItems - An array of items that have been added to the shopping cart. - * @returns {Array} products - An array of products, parent variant and variants in the catalog - */ -export async function findCatalogProductsAndVariants(collections, orderLineItems) { - const { Catalog } = collections; - const productIds = orderLineItems.map((orderLineItem) => orderLineItem.productId); - - const catalogProductItems = await Catalog.find({ - "product.productId": { $in: productIds }, - "product.isVisible": true, - "product.isDeleted": { $ne: true }, - "isDeleted": { $ne: true } - }).toArray(); - - const catalogProductsAndVariants = catalogProductItems.map((catalogProduct) => { - const { product } = catalogProduct; - const orderedVariant = orderLineItems.find((item) => product.productId === item.productId); - - const { parentVariant, variant } = findVariantInCatalogProduct(product, orderedVariant.variantId); - - return { - product, - parentVariant, - variant - }; - }); - - return catalogProductsAndVariants; -} - /** * @name mergeProductAndVariants * @summary Merges a product and its variants @@ -46,11 +11,11 @@ export function mergeProductAndVariants(productAndVariants) { // Filter out unnecessary product props const productProps = _.omit(product, [ - "variants", "media", "metafields", "parcel", "pricing", " primaryImage", "socialMetadata", "customAttributes" + "variants", "media", "metafields", "parcel", " primaryImage", "socialMetadata", "customAttributes" ]); // Filter out unnecessary variant props - const variantExcludeProps = ["media", "parcel", "pricing", "primaryImage", "customAttributes"]; + const variantExcludeProps = ["media", "parcel", "primaryImage", "customAttributes"]; const variantProps = _.omit(variant, variantExcludeProps); // If an option has been added to the cart diff --git a/imports/plugins/core/taxes/server/no-meteor/mutateNewVariantBeforeCreate.js b/imports/plugins/core/taxes/server/no-meteor/mutateNewVariantBeforeCreate.js index 903d069efb7..9a40a75e5c6 100644 --- a/imports/plugins/core/taxes/server/no-meteor/mutateNewVariantBeforeCreate.js +++ b/imports/plugins/core/taxes/server/no-meteor/mutateNewVariantBeforeCreate.js @@ -4,14 +4,14 @@ * @param {Object} input Input data * @returns {undefined} */ -export default async function mutateNewVariantBeforeCreate(newVariant, { context, product, isOption }) { +export default async function mutateNewVariantBeforeCreate(newVariant, { context, isOption }) { // Tax fields are managed by and inherited from top-level variant if (!isOption) { // All new variants are taxable by default newVariant.isTaxable = true; // Give new variants the default tax code, if one is set - const plugin = await context.collections.Packages.findOne({ name: "reaction-taxes", shopId: product.shopId }); + const plugin = await context.collections.Packages.findOne({ name: "reaction-taxes", shopId: newVariant.shopId }); if (!plugin) return; const { defaultTaxCode } = plugin.settings || {}; diff --git a/imports/plugins/core/ui/client/components/tags/tagItem.js b/imports/plugins/core/ui/client/components/tags/tagItem.js index e1411a1b324..5a79217b5d3 100644 --- a/imports/plugins/core/ui/client/components/tags/tagItem.js +++ b/imports/plugins/core/ui/client/components/tags/tagItem.js @@ -318,7 +318,11 @@ TagItem.propTypes = { onTagUpdate: PropTypes.func, parentTag: PropTypes.object, suggestions: PropTypes.arrayOf(PropTypes.object), - tag: PropTypes.object + tag: PropTypes.shape({ + _id: PropTypes.string, // newTag will not have an _id + name: PropTypes.string.isRequired, + slug: PropTypes.string + }) }; registerComponent("TagItem", TagItem); diff --git a/imports/plugins/core/ui/client/components/tags/tagList.js b/imports/plugins/core/ui/client/components/tags/tagList.js index 70f3ad99ab8..cb93af260c8 100644 --- a/imports/plugins/core/ui/client/components/tags/tagList.js +++ b/imports/plugins/core/ui/client/components/tags/tagList.js @@ -3,7 +3,6 @@ import PropTypes from "prop-types"; import _ from "lodash"; import classnames from "classnames"; import { Components } from "@reactioncommerce/reaction-components"; -import { PropTypes as ReactionPropTypes } from "/lib/api"; class TagList extends Component { displayName = "Tag List (TagList)"; @@ -138,7 +137,9 @@ class TagList extends Component { } render() { - if (this.props.isTagNav) { + const { editable, isTagNav, parentTag } = this.props; + + if (isTagNav) { return (
{this.renderTags()} @@ -149,13 +150,13 @@ class TagList extends Component { const classes = classnames({ rui: true, tags: true, - edit: this.props.editable + edit: editable }); return (
{this.renderTags()} @@ -165,7 +166,7 @@ class TagList extends Component { } TagList.defaultProps = { - parentTag: {} + parentTag: null }; TagList.propTypes = { @@ -189,10 +190,14 @@ TagList.propTypes = { onTagSave: PropTypes.func, onTagSort: PropTypes.func, onTagUpdate: PropTypes.func, - parentTag: ReactionPropTypes.Tag, + parentTag: PropTypes.shape({ + _id: PropTypes.string.isRequired + }), showBookmark: PropTypes.bool, // eslint-disable-line react/boolean-prop-naming suggestions: PropTypes.arrayOf(PropTypes.object), - tags: ReactionPropTypes.arrayOfTags + tags: PropTypes.arrayOf(PropTypes.shape({ + _id: PropTypes.string.isRequired + })) }; export default TagList; diff --git a/imports/plugins/core/versions/server/util/convertCatalogItem.js b/imports/plugins/core/versions/server/util/convertCatalogItem.js index 399fac75dc1..837551dfb98 100644 --- a/imports/plugins/core/versions/server/util/convertCatalogItem.js +++ b/imports/plugins/core/versions/server/util/convertCatalogItem.js @@ -1,5 +1,5 @@ import Logger from "@reactioncommerce/logger"; -import getPriceRange from "/imports/plugins/core/catalog/server/no-meteor/utils/getPriceRange"; +import getPriceRange from "./getPriceRange"; /** * @method diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/getDisplayPrice.js b/imports/plugins/core/versions/server/util/getDisplayPrice.js similarity index 100% rename from imports/plugins/core/catalog/server/no-meteor/utils/getDisplayPrice.js rename to imports/plugins/core/versions/server/util/getDisplayPrice.js diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/getPriceRange.js b/imports/plugins/core/versions/server/util/getPriceRange.js similarity index 100% rename from imports/plugins/core/catalog/server/no-meteor/utils/getPriceRange.js rename to imports/plugins/core/versions/server/util/getPriceRange.js diff --git a/imports/plugins/included/simple-pricing/README.md b/imports/plugins/included/simple-pricing/README.md index eafcf855270..92c0556b17b 100644 --- a/imports/plugins/included/simple-pricing/README.md +++ b/imports/plugins/included/simple-pricing/README.md @@ -2,45 +2,10 @@ Pricing data falls under it's own domain within the Reaction Commerce system, however it currently needs to be intertwined and available from other system domains (i.e., Catalog, Cart, Checkout, Orders). To give us more flexibility in pricing data management we've begun to move pricing get/set functions into this `simple-pricing` plugin and calling these functions from the `context.queries` object from within their respective functions. Now we can fully replace the pricing management system without modification to core by creating a custom plugin that replaces the `simple-pricing` queries. -**Example** - -``` js -// old addCartItems() funcitons - -... -const { - catalogProduct, - parentVariant, - variant: chosenVariant - } = await findProductAndVariant(collections, productId, productVariantId); - - const variantPriceInfo = chosenVariant.pricing[providedPrice.currencyCode]; - if (!variantPriceInfo) { - throw new ReactionError("invalid-param", `This product variant does not have a price for ${price.currencyCode}`); - } -... - -// new addCartItems() function with simple-pricing -const { - catalogProduct, - parentVariant, - variant: chosenVariant - } = await findProductAndVariant(collections, productId, productVariantId); - - const variantPriceInfo = await queries.getVariantPrice(context, chosenVariant, currencyCode); - if (!variantPriceInfo) { - throw new ReactionError("invalid-param", `This product variant does not have a price for ${price.currencyCode}`); - } -``` - ## Queries -### Price Queries -**getVariantPrice** -This query is used to get a selected product's real price. +Any pricing plugin is expected to define and register the following query functions. -**getCurrentCatalogPriceForProductConfiguration** -This query is used to verify a product's price is correct before we process the order. +### getVariantPrice -### Catalog Price Queries -TBD +This query function is used to get a product variant's real current price. diff --git a/imports/plugins/included/simple-pricing/register.js b/imports/plugins/included/simple-pricing/register.js index c91efec2d74..e943d6f4d6e 100644 --- a/imports/plugins/included/simple-pricing/register.js +++ b/imports/plugins/included/simple-pricing/register.js @@ -1,23 +1,4 @@ import Reaction from "/imports/plugins/core/core/server/Reaction"; -import queries from "./server/no-meteor/queries"; -import startup from "./server/no-meteor/startup"; +import register from "./server/no-meteor/register"; -/** - * Simple Pricing plugin - * Isolates the get/set of pricing data to this plugin. - */ - -Reaction.registerPackage({ - label: "Pricing", - name: "reaction-pricing", - icon: "fa fa-dollar-sign", - autoEnable: true, - functionsByType: { - startup: [startup] - }, - graphQL: {}, - queries, - settings: { - name: "Pricing" - } -}); +Reaction.whenAppInstanceReady(register); diff --git a/imports/plugins/included/simple-pricing/server/extendCoreSchemas.js b/imports/plugins/included/simple-pricing/server/extendCoreSchemas.js new file mode 100644 index 00000000000..df811e27ce9 --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/extendCoreSchemas.js @@ -0,0 +1,78 @@ +import SimpleSchema from "simpl-schema"; +import { registerSchema } from "@reactioncommerce/schemas"; +import { + CatalogProduct, + CatalogVariantSchema, + Product, + ProductVariant, + VariantBaseSchema +} from "/imports/collections/schemas"; + +/** + * @name PriceRange + * @type {SimpleSchema} + * @memberof Schemas + * @property {String} range, default value: `"0.00"` + * @property {Number} min optional, default value: `0` + * @property {Number} max optional, default value: `0` + */ +const PriceRange = new SimpleSchema({ + range: { + type: String, + defaultValue: "0.00" + }, + min: { + type: Number, + defaultValue: 0, + optional: true + }, + max: { + type: Number, + defaultValue: 0, + optional: true + } +}); + +registerSchema("PriceRange", PriceRange); + +Product.extend({ + price: { + label: "Price", + type: PriceRange + } +}); + +ProductVariant.extend({ + compareAtPrice: { + label: "Compare At Price", + type: Number, + optional: true, + min: 0, + defaultValue: 0.00 + }, + price: { + label: "Price", + type: Number, + defaultValue: 0.00, + min: 0, + optional: true + } +}); + +CatalogProduct.extend({ + pricing: { + type: Object, + blackbox: true + } +}); + +// Extend the catalog variant database schemas +const variantSchemaExtension = { + pricing: { + type: Object, + blackbox: true + } +}; + +VariantBaseSchema.extend(variantSchemaExtension); +CatalogVariantSchema.extend(variantSchemaExtension); diff --git a/imports/plugins/included/simple-pricing/server/index.js b/imports/plugins/included/simple-pricing/server/index.js new file mode 100644 index 00000000000..7245b45d206 --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/index.js @@ -0,0 +1 @@ +import "./extendCoreSchemas"; diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/queries/getCurrentCatalogPriceForProductConfiguration.js b/imports/plugins/included/simple-pricing/server/no-meteor/queries/getCurrentCatalogPriceForProductConfiguration.js deleted file mode 100644 index 75738078d54..00000000000 --- a/imports/plugins/included/simple-pricing/server/no-meteor/queries/getCurrentCatalogPriceForProductConfiguration.js +++ /dev/null @@ -1,26 +0,0 @@ -import findProductAndVariant from "/imports/plugins/core/catalog/server/no-meteor/utils/findProductAndVariant"; - -/** - * @summary Returns the current price in the Catalog for the given product configuration - * @param {Object} productConfiguration The ProductConfiguration object - * @param {String} currencyCode The currency code - * @param {Object} collections Map of MongoDB collections - * @returns {Object} Object with `price` as the current price in the Catalog for the given product configuration. - * Also returns catalogProduct and catalogProductVariant docs in case you need them. - */ -export default async function getCurrentCatalogPriceForProductConfiguration(productConfiguration, currencyCode, collections) { - const { productId, productVariantId } = productConfiguration; - const { - catalogProduct, - variant: catalogProductVariant - } = await findProductAndVariant(collections, productId, productVariantId); - - const variantPriceInfo = (catalogProductVariant.pricing && catalogProductVariant.pricing[currencyCode]) || {}; - const price = variantPriceInfo.price || catalogProductVariant.price; - - return { - catalogProduct, - catalogProductVariant, - price - }; -} diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/queries/getVariantPrice.js b/imports/plugins/included/simple-pricing/server/no-meteor/queries/getVariantPrice.js index 9b9ef0352ee..0231531065c 100644 --- a/imports/plugins/included/simple-pricing/server/no-meteor/queries/getVariantPrice.js +++ b/imports/plugins/included/simple-pricing/server/no-meteor/queries/getVariantPrice.js @@ -7,5 +7,9 @@ * @return {Object} - A cart item price value. */ export default function getVariantPrice(context, catalogVariant, currencyCode) { - return catalogVariant.pricing[currencyCode]; + if (!currencyCode) throw new Error("getVariantPrice received no currency code"); + if (!catalogVariant) throw new Error("getVariantPrice received no catalogVariant"); + if (!catalogVariant.pricing) throw new Error(`Catalog variant ${catalogVariant._id} has no pricing information saved`); + + return catalogVariant.pricing[currencyCode] || {}; } diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/queries/index.js b/imports/plugins/included/simple-pricing/server/no-meteor/queries/index.js index c94fc10ff52..0f0d4c103e4 100644 --- a/imports/plugins/included/simple-pricing/server/no-meteor/queries/index.js +++ b/imports/plugins/included/simple-pricing/server/no-meteor/queries/index.js @@ -1,7 +1,5 @@ import getVariantPrice from "./getVariantPrice"; -import getCurrentCatalogPriceForProductConfiguration from "./getCurrentCatalogPriceForProductConfiguration"; export default { - getVariantPrice, - getCurrentCatalogPriceForProductConfiguration + getVariantPrice }; diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/register.js b/imports/plugins/included/simple-pricing/server/no-meteor/register.js new file mode 100644 index 00000000000..3c4e844613a --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/no-meteor/register.js @@ -0,0 +1,37 @@ +import queries from "./queries"; +import resolvers from "./resolvers"; +import schemas from "./schemas"; +import startup from "./startup"; +import getMinPriceSortByFieldPath from "./util/getMinPriceSortByFieldPath"; +import mutateNewProductBeforeCreate from "./util/mutateNewProductBeforeCreate"; +import mutateNewVariantBeforeCreate from "./util/mutateNewVariantBeforeCreate"; +import publishProductToCatalog from "./util/publishProductToCatalog"; + +/** + * @summary Import and call this function to add this plugin to your API. + * @param {ReactionNodeApp} app The ReactionNodeApp instance + * @return {undefined} + */ +export default async function register(app) { + await app.registerPlugin({ + label: "Pricing", + name: "reaction-pricing", + icon: "fa fa-dollar-sign", + functionsByType: { + getMinPriceSortByFieldPath: [getMinPriceSortByFieldPath], + mutateNewProductBeforeCreate: [mutateNewProductBeforeCreate], + mutateNewVariantBeforeCreate: [mutateNewVariantBeforeCreate], + publishProductToCatalog: [publishProductToCatalog], + startup: [startup] + }, + graphQL: { + resolvers, + schemas + }, + queries, + catalog: { + publishedProductFields: ["price"], + publishedProductVariantFields: ["price"] + } + }); +} diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/resolvers/CatalogProduct.js b/imports/plugins/included/simple-pricing/server/no-meteor/resolvers/CatalogProduct.js new file mode 100644 index 00000000000..aa9e94a6147 --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/no-meteor/resolvers/CatalogProduct.js @@ -0,0 +1,5 @@ +import xformPricingArray from "../util/xformPricingArray"; + +export default { + pricing: (node) => xformPricingArray(node.pricing) +}; diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/resolvers/CatalogProductVariant.js b/imports/plugins/included/simple-pricing/server/no-meteor/resolvers/CatalogProductVariant.js new file mode 100644 index 00000000000..aa9e94a6147 --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/no-meteor/resolvers/CatalogProductVariant.js @@ -0,0 +1,5 @@ +import xformPricingArray from "../util/xformPricingArray"; + +export default { + pricing: (node) => xformPricingArray(node.pricing) +}; diff --git a/imports/plugins/core/catalog/server/no-meteor/resolvers/ProductPricingInfo.js b/imports/plugins/included/simple-pricing/server/no-meteor/resolvers/ProductPricingInfo.js similarity index 66% rename from imports/plugins/core/catalog/server/no-meteor/resolvers/ProductPricingInfo.js rename to imports/plugins/included/simple-pricing/server/no-meteor/resolvers/ProductPricingInfo.js index 150c5b29ec3..0cf2cda2a8c 100644 --- a/imports/plugins/core/catalog/server/no-meteor/resolvers/ProductPricingInfo.js +++ b/imports/plugins/included/simple-pricing/server/no-meteor/resolvers/ProductPricingInfo.js @@ -1,4 +1,5 @@ -import { getXformedCurrencyByCode, xformCurrencyExchangePricing } from "@reactioncommerce/reaction-graphql-xforms/currency"; +import { getXformedCurrencyByCode } from "@reactioncommerce/reaction-graphql-xforms/currency"; +import xformCurrencyExchangePricing from "../util/xformCurrencyExchangePricing"; export default { currency: (node) => getXformedCurrencyByCode(node.currencyCode), diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/resolvers/index.js b/imports/plugins/included/simple-pricing/server/no-meteor/resolvers/index.js new file mode 100644 index 00000000000..3985bd193aa --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/no-meteor/resolvers/index.js @@ -0,0 +1,9 @@ +import CatalogProduct from "./CatalogProduct"; +import CatalogProductVariant from "./CatalogProductVariant"; +import ProductPricingInfo from "./ProductPricingInfo"; + +export default { + CatalogProduct, + CatalogProductVariant, + ProductPricingInfo +}; diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/schemas/index.js b/imports/plugins/included/simple-pricing/server/no-meteor/schemas/index.js new file mode 100644 index 00000000000..cc293a21b1e --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/no-meteor/schemas/index.js @@ -0,0 +1,3 @@ +import schema from "./schema.graphql"; + +export default [schema]; diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/schemas/schema.graphql b/imports/plugins/included/simple-pricing/server/no-meteor/schemas/schema.graphql new file mode 100644 index 00000000000..5b62857a9a3 --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/no-meteor/schemas/schema.graphql @@ -0,0 +1,78 @@ +"The product price or price range for a specific currency" +type ProductPricingInfo { + """ + A comparison price value, usually MSRP. If `price` is null, this will also be null. That is, + only purchasable variants will have a `compareAtPrice`. + """ + compareAtPrice: Money + + "The code for the currency these pricing details applies to" + currency: Currency! + + "Pricing converted to specified currency" + currencyExchangePricing( + currencyCode: String! + ): CurrencyExchangeProductPricingInfo + + """ + UI should display this price. If a product has multiple potential prices depending on selected + variants and options, then this is a price range string such as "$3.95 - $6.99". It includes the currency + symbols. + """ + displayPrice: String! + + "The price of the most expensive possible variant+option combination" + maxPrice: Float! + + "The price of the least expensive possible variant+option combination" + minPrice: Float! + + """ + For variants with no options and for options, this will always be set to a price. For variants + with options and products, this will be `null`. There must be a price for a variant to be + added to a cart or purchased. Otherwise you would instead add one of its child options to a cart. + """ + price: Float +} + +"The product price or price range for a specific currency" +type CurrencyExchangeProductPricingInfo { + """ + A comparison price value, usually MSRP. If `price` is null, this will also be null. That is, + only purchasable variants will have a `compareAtPrice`. + """ + compareAtPrice: Money + + "The code for the currency these pricing details applies to" + currency: Currency! + + """ + UI should display this price. If a product has multiple potential prices depending on selected + variants and options, then this is a price range string such as "$3.95 - $6.99". It includes the currency + symbols. + """ + displayPrice: String! + + "The price of the most expensive possible variant+option combination" + maxPrice: Float! + + "The price of the least expensive possible variant+option combination" + minPrice: Float! + + """ + For variants with no options and for options, this will always be set to a price. For variants + with options and products, this will be `null`. There must be a price for a variant to be + added to a cart or purchased. Otherwise you would instead add one of its child options to a cart. + """ + price: Float +} + +extend type CatalogProduct { + "Price and related information, per currency" + pricing: [ProductPricingInfo]! +} + +extend type CatalogProductVariant { + "Price and related information, per currency" + pricing: [ProductPricingInfo]! +} diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/startup.js b/imports/plugins/included/simple-pricing/server/no-meteor/startup.js index dbba193edb3..a4cdff2baab 100644 --- a/imports/plugins/included/simple-pricing/server/no-meteor/startup.js +++ b/imports/plugins/included/simple-pricing/server/no-meteor/startup.js @@ -1,8 +1,74 @@ +import collectionIndex from "/imports/utils/collectionIndex"; +import getProductPriceRange from "./util/getProductPriceRange"; +import getVariantPriceRange from "./util/getVariantPriceRange"; + +const fieldsThatChangeAncestorPricing = ["isDeleted", "isVisible", "price"]; + /** - * * @method startup * @summary Simple pricing startup function. * @param {Object} context - App context. - * @return {undefin} - void, no return. + * @return {undefined} - void, no return. */ -export default function startup() {} +export default async function startup(context) { + const { appEvents, collections } = context; + const { Catalog, Products, Shops } = collections; + + // Add an index to support built-in minPrice sorting for the primary shop's + // default currency code only. + const shop = await Shops.findOne({ shopType: "primary" }); + if (shop.currency) { + collectionIndex(Catalog, { + [`product.pricing.${shop.currency}.minPrice`]: 1, + _id: 1 + }); + } + + /** + * Updates the `price` field for a Products collection product based on its + * updated variants. + * @param {Object} variant The updated variant object + * @return {undefined} + */ + async function updateProductPrice(variant) { + const productDocs = await Products.find({ + $or: [ + { _id: { $in: variant.ancestors } }, + { ancestors: { $in: variant.ancestors } } + ] + }).toArray(); + + // productDocs has all sibling and ancestor docs from Products. + // We now want to update `price` field only for those that are + // not siblings. + /* eslint-disable no-await-in-loop */ + for (const productDoc of productDocs) { + if (productDoc.ancestors.length < variant.ancestors.length) { + let price; + if (productDoc.ancestors.length === 0) { + price = getProductPriceRange(productDoc._id, productDocs); + } else { + price = getVariantPriceRange(productDoc._id, productDocs); + } + + await Products.updateOne({ _id: productDoc._id }, { + $set: { price } + }); + } + } + /* eslint-enable no-await-in-loop */ + } + + // Listen for variant soft deletion from the Products collection, and recalculate + // the price range for the parent variant and product + appEvents.on("afterVariantSoftDelete", async ({ variant }) => updateProductPrice(variant)); + + // Listen for variant price changes in the Products collection, and recalculate + // the price range for the parent variant and product + appEvents.on("afterVariantUpdate", async ({ _id, field }) => { + if (!fieldsThatChangeAncestorPricing.includes(field)) return; + + const variant = await Products.findOne({ _id }); + await updateProductPrice(variant); + }); +} diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/util/getDisplayPrice.js b/imports/plugins/included/simple-pricing/server/no-meteor/util/getDisplayPrice.js new file mode 100644 index 00000000000..41ead71dfc8 --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/no-meteor/util/getDisplayPrice.js @@ -0,0 +1,37 @@ +import { formatMoney } from "accounting-js"; + +/** + * @name getDisplayPrice + * @method + * @summary Returns a price for front-end display in the given currency + * @param {Number} minPrice Minimum price + * @param {Number} maxPrice Maximum price + * @param {Object} currencyInfo Currency object from Reaction shop schema + * @returns {String} Display price with currency symbol(s) + */ +export default function getDisplayPrice(minPrice, maxPrice, currencyInfo = { symbol: "" }) { + let displayPrice; + + if (minPrice === maxPrice) { + // Display 1 price (min = max) + displayPrice = formatMoney(minPrice, currencyInfo); + } else { + // Display range + let minFormatted; + + // Account for currencies where only one currency symbol should be displayed. Ex: 680,18 - 1 359,68 руб. + if (currencyInfo.where === "right") { + const modifiedCurrencyInfo = Object.assign({}, currencyInfo, { + symbol: "" + }); + minFormatted = formatMoney(minPrice, modifiedCurrencyInfo).trim(); + } else { + minFormatted = formatMoney(minPrice, currencyInfo); + } + + const maxFormatted = formatMoney(maxPrice, currencyInfo); + displayPrice = `${minFormatted} - ${maxFormatted}`; + } + + return displayPrice; +} diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/getDisplayPrice.test.js b/imports/plugins/included/simple-pricing/server/no-meteor/util/getDisplayPrice.test.js similarity index 100% rename from imports/plugins/core/catalog/server/no-meteor/utils/getDisplayPrice.test.js rename to imports/plugins/included/simple-pricing/server/no-meteor/util/getDisplayPrice.test.js diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/util/getMinPriceSortByFieldPath.js b/imports/plugins/included/simple-pricing/server/no-meteor/util/getMinPriceSortByFieldPath.js new file mode 100644 index 00000000000..e4af4786fcd --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/no-meteor/util/getMinPriceSortByFieldPath.js @@ -0,0 +1,12 @@ +/** + * + */ +export default function getMinPriceSortByFieldPath(context, { connectionArgs }) { + const { sortByPriceCurrencyCode } = connectionArgs || {}; + + if (typeof sortByPriceCurrencyCode !== "string") { + throw new Error("sortByPriceCurrencyCode is required when sorting by minPrice"); + } + + return `product.pricing.${sortByPriceCurrencyCode}.minPrice`; +} diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/util/getPriceRange.js b/imports/plugins/included/simple-pricing/server/no-meteor/util/getPriceRange.js new file mode 100644 index 00000000000..167609d6c12 --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/no-meteor/util/getPriceRange.js @@ -0,0 +1,38 @@ +import getDisplayPrice from "./getDisplayPrice"; + +/** + * + * @method getPriceRange + * @summary Get Price object from array of Product prices + * @param {Array} prices - Array of Product price properties + * @param {Object} [currencyInfo] - A currency object in Reaction schema + * @return {Promise} PriceRange object + */ +export default function getPriceRange(prices, currencyInfo) { + if (prices.length === 1) { + const price = prices[0]; + return { + range: getDisplayPrice(price, price, currencyInfo), + min: price, + max: price + }; + } + + let priceMin = Number.POSITIVE_INFINITY; + let priceMax = Number.NEGATIVE_INFINITY; + + prices.forEach((price) => { + if (price < priceMin) { + priceMin = price; + } + if (price > priceMax) { + priceMax = price; + } + }); + + return { + range: getDisplayPrice(priceMin, priceMax, currencyInfo), + min: priceMin, + max: priceMax + }; +} diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/getPriceRange.test.js b/imports/plugins/included/simple-pricing/server/no-meteor/util/getPriceRange.test.js similarity index 100% rename from imports/plugins/core/catalog/server/no-meteor/utils/getPriceRange.test.js rename to imports/plugins/included/simple-pricing/server/no-meteor/util/getPriceRange.test.js diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/util/getProductPriceRange.js b/imports/plugins/included/simple-pricing/server/no-meteor/util/getProductPriceRange.js new file mode 100644 index 00000000000..631363675cb --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/no-meteor/util/getProductPriceRange.js @@ -0,0 +1,23 @@ +import getPriceRange from "./getPriceRange"; +import getVariantPriceRange from "./getVariantPriceRange"; + +/** + * + * @method getProductPriceRange + * @summary Get the PriceRange object for a Product by ID + * @param {String} productId - A product ID + * @param {Object[]} variants - A list of documents from a Products.find call + * @return {Object} Product PriceRange object + */ +export default function getProductPriceRange(productId, variants) { + const visibleVariants = variants.filter((option) => option.ancestors.length === 1 && + option.isVisible && !option.isDeleted); + if (visibleVariants.length === 0) return getPriceRange([0]); + + const variantPrices = []; + visibleVariants.forEach((variant) => { + const { min, max } = getVariantPriceRange(variant._id, variants); + variantPrices.push(min, max); + }); + return getPriceRange(variantPrices); +} diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/util/getProductPriceRange.test.js b/imports/plugins/included/simple-pricing/server/no-meteor/util/getProductPriceRange.test.js new file mode 100644 index 00000000000..d99c19f3cc8 --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/no-meteor/util/getProductPriceRange.test.js @@ -0,0 +1,122 @@ +import { rewire as rewire$getVariantPriceRange, restore as restore$getVariantPriceRange } from "./getVariantPriceRange"; +import getProductPriceRange from "./getProductPriceRange"; + +const mockGetVariantPriceRange = jest.fn().mockName("getVariantPriceRange"); + +const internalShopId = "123"; +const internalCatalogProductId = "999"; +const internalVariantIds = ["875", "874"]; + +const createdAt = new Date("2018-04-16T15:34:28.043Z"); +const updatedAt = new Date("2018-04-17T15:34:28.043Z"); + +const mockVariants = [ + { + _id: internalVariantIds[0], + ancestors: [internalCatalogProductId], + barcode: "barcode", + createdAt, + height: 0, + index: 0, + inventoryManagement: true, + inventoryPolicy: false, + isDeleted: false, + isLowQuantity: true, + isSoldOut: false, + isVisible: true, + length: 0, + lowInventoryWarningThreshold: 0, + metafields: [ + { + value: "value", + namespace: "namespace", + description: "description", + valueType: "valueType", + scope: "scope", + key: "key" + } + ], + minOrderQuantity: 0, + optionTitle: "Untitled Option", + originCountry: "US", + price: 0, + shopId: internalShopId, + sku: "sku", + taxCode: "0000", + taxDescription: "taxDescription", + title: "Small Concrete Pizza", + updatedAt, + variantId: internalVariantIds[0], + weight: 0, + width: 0 + }, + { + _id: internalVariantIds[1], + ancestors: [internalCatalogProductId, internalVariantIds[0]], + barcode: "barcode", + height: 2, + index: 0, + inventoryManagement: true, + inventoryPolicy: true, + isDeleted: false, + isLowQuantity: true, + isSoldOut: false, + isVisible: true, + length: 2, + lowInventoryWarningThreshold: 0, + metafields: [ + { + value: "value", + namespace: "namespace", + description: "description", + valueType: "valueType", + scope: "scope", + key: "key" + } + ], + minOrderQuantity: 0, + optionTitle: "Awesome Soft Bike", + originCountry: "US", + price: 2.99, + shopId: internalShopId, + sku: "sku", + taxCode: "0000", + taxDescription: "taxDescription", + title: "One pound bag", + variantId: internalVariantIds[1], + weight: 2, + width: 2 + } +]; + +const mockPriceRange = { + range: "2.99 - 5.99", + max: 5.99, + min: 2.99 +}; + +beforeAll(() => { + rewire$getVariantPriceRange(mockGetVariantPriceRange); +}); + +afterAll(() => { + restore$getVariantPriceRange(); +}); + +// expect a legit price range +test("expect to return a promise that resolves to a product price object", () => { + mockGetVariantPriceRange + .mockReturnValueOnce(mockPriceRange) + .mockReturnValueOnce(mockPriceRange); + const spec = getProductPriceRange("999", mockVariants); + expect(spec).toEqual(mockPriceRange); +}); + +// expect an empty price range +test("expect to throw an error if no product is found", () => { + try { + getProductPriceRange("badID", mockVariants); + } catch (error) { + expect(error).toEqual("Product not found"); + } +}); diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/util/getVariantPriceRange.js b/imports/plugins/included/simple-pricing/server/no-meteor/util/getVariantPriceRange.js new file mode 100644 index 00000000000..9b6227cf44d --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/no-meteor/util/getVariantPriceRange.js @@ -0,0 +1,22 @@ +import getPriceRange from "./getPriceRange"; + +/** + * + * @method getVariantPriceRange + * @summary Create a Product PriceRange object by taking the lowest variant price and the highest variant + * price to create the PriceRange. If only one variant use that variant's price to create the PriceRange + * @param {String} variantId - A product variant ID. + * @param {Object[]} variants - A list of documents from a Products.find call + * @return {Object} Product PriceRange object + */ +export default function getVariantPriceRange(variantId, variants) { + const visibleOptions = variants.filter((option) => option.ancestors.includes(variantId) && + option.isVisible && !option.isDeleted); + if (visibleOptions.length === 0) { + const thisVariant = variants.find((option) => option._id === variantId); + return getPriceRange([(thisVariant && thisVariant.price) || 0]); + } + + const prices = visibleOptions.map((option) => option.price); + return getPriceRange(prices); +} diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/util/getVariantPriceRange.test.js b/imports/plugins/included/simple-pricing/server/no-meteor/util/getVariantPriceRange.test.js new file mode 100644 index 00000000000..bb29319b5fe --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/no-meteor/util/getVariantPriceRange.test.js @@ -0,0 +1,73 @@ +import getVariantPriceRange from "./getVariantPriceRange"; + +const internalCatalogProductId = "999"; +const internalVariantIds = ["875", "874", "873"]; + +const mockVariants = [ + { + _id: internalVariantIds[0], + ancestors: [internalCatalogProductId], + isDeleted: false, + isVisible: true, + price: 2.99 + }, + { + _id: internalVariantIds[1], + ancestors: [internalCatalogProductId, internalVariantIds[0]], + isDeleted: false, + isVisible: true, + price: 5.99 + }, + { + _id: internalVariantIds[2], + ancestors: [internalCatalogProductId, internalVariantIds[0]], + isDeleted: false, + isVisible: true, + price: 3.99 + } +]; + +// expect topVariant price if no children +test("expect topVariants price string if no child variants", () => { + const spec = getVariantPriceRange(internalVariantIds[0], mockVariants.slice(0, 1)); + const success = { + range: "2.99", + max: 2.99, + min: 2.99 + }; + expect(spec).toEqual(success); +}); + +// expect child variant price if only one child variant +test("expect child variant price string if only one child variant", () => { + const spec = getVariantPriceRange(internalVariantIds[0], mockVariants.slice(0, 2)); + const success = { + range: "5.99", + max: 5.99, + min: 5.99 + }; + expect(spec).toEqual(success); +}); + +// expect a price rang string of the min price and max price +test("expect price range string if variants have different prices", () => { + const spec = getVariantPriceRange(internalVariantIds[0], mockVariants); + const success = { + range: "3.99 - 5.99", + max: 5.99, + min: 3.99 + }; + expect(spec).toEqual(success); +}); + +// expect variant min price if min and max price are equal +test("expect variant price string if variants have same price", () => { + mockVariants[2].price = 5.99; + const spec = getVariantPriceRange(internalVariantIds[0], mockVariants); + const success = { + range: "5.99", + max: 5.99, + min: 5.99 + }; + expect(spec).toEqual(success); +}); diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/util/mutateNewProductBeforeCreate.js b/imports/plugins/included/simple-pricing/server/no-meteor/util/mutateNewProductBeforeCreate.js new file mode 100644 index 00000000000..96e0858ff2a --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/no-meteor/util/mutateNewProductBeforeCreate.js @@ -0,0 +1,14 @@ +/** + * @summary Mutates a new top-level Product being created + * @param {Object} product Product object to mutate + * @return {undefined} + */ +export default function mutateNewProductBeforeCreate(product) { + if (!product.price) { + product.price = { + range: "0.00 - 0.00", + min: 0, + max: 0 + }; + } +} diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/util/mutateNewVariantBeforeCreate.js b/imports/plugins/included/simple-pricing/server/no-meteor/util/mutateNewVariantBeforeCreate.js new file mode 100644 index 00000000000..c2b97a8c980 --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/no-meteor/util/mutateNewVariantBeforeCreate.js @@ -0,0 +1,8 @@ +/** + * @summary Mutates a new top-level Product being created + * @param {Object} variant Variant product object to mutate + * @return {undefined} + */ +export default function mutateNewVariantBeforeCreate(variant) { + if (!variant.price) variant.price = 0; +} diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/util/publishProductToCatalog.js b/imports/plugins/included/simple-pricing/server/no-meteor/util/publishProductToCatalog.js new file mode 100644 index 00000000000..c583400c47e --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/no-meteor/util/publishProductToCatalog.js @@ -0,0 +1,77 @@ +import getPriceRange from "./getPriceRange"; + +/** + * @summary Get the pricing object + * @param {Object} doc The Products collection document + * @param {Object} priceInfo A price range object, with `range`, `min`, and `max` fields + * @returns {Object} Object for `pricing` field for product, variant, or option + */ +function getPricingObject(doc, priceInfo) { + return { + compareAtPrice: doc.compareAtPrice || null, + displayPrice: priceInfo.range, + maxPrice: priceInfo.max, + minPrice: priceInfo.min, + price: typeof doc.price === "number" ? doc.price : null + }; +} + +/** + * @summary Functions of type "publishProductToCatalog" are expected to mutate the provided catalogProduct. + * This function is called every time a product is published to a catalog. + * @param {Object} catalogProduct The catalog product being built, to save in the Catalog collection. Mutates this. + * @param {Object} input Input object + * @param {Object} input.product The product being published, in Products collection schema + * @param {Object} input.shop The shop that owns the product being published, in Shops collection schema + * @param {Object[]} input.variants All variants and options of the product being published, in Products collection schema + * @return {undefined} No return. Mutates `catalogProduct` object. + */ +export default function publishProductToCatalog(catalogProduct, { product, shop, variants }) { + const shopCurrencyCode = shop.currency; + const shopCurrencyInfo = shop.currencies[shopCurrencyCode]; + + const prices = []; + + for (const variant of catalogProduct.variants) { + const { options } = variant; + + const sourceVariant = variants.find((productVariant) => productVariant._id === variant.variantId); + if (!sourceVariant) throw new Error(`Variant ${variant.variantId} not found in variants list`); + + let variantPriceInfo; + if (Array.isArray(options) && options.length) { + const optionPrices = []; + for (const option of options) { + const sourceOptionVariant = variants.find((productVariant) => productVariant._id === option.variantId); + if (!sourceOptionVariant) throw new Error(`Variant ${option.variantId} not found in variants list`); + + optionPrices.push(sourceOptionVariant.price); + const optionPriceInfo = getPriceRange([sourceOptionVariant.price], shopCurrencyInfo); + + // Mutate the option, adding pricing field + option.pricing = { + [shopCurrencyCode]: getPricingObject(sourceOptionVariant, optionPriceInfo) + }; + } + variantPriceInfo = getPriceRange(optionPrices, shopCurrencyInfo); + } else { + variantPriceInfo = getPriceRange([sourceVariant.price], shopCurrencyInfo); + } + prices.push(variantPriceInfo.min, variantPriceInfo.max); + + // Mutate the variant, adding pricing field + variant.pricing = { + [shopCurrencyCode]: getPricingObject(sourceVariant, variantPriceInfo) + }; + } + + const productPriceInfo = getPriceRange(prices, shopCurrencyInfo); + + // Mutate the product, adding pricing field + catalogProduct.pricing = { + [shopCurrencyCode]: getPricingObject(product, productPriceInfo) + }; + + // Make extra sure the product price is always `null` + catalogProduct.pricing[shopCurrencyCode].price = null; +} diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/util/xformCurrencyExchangePricing.js b/imports/plugins/included/simple-pricing/server/no-meteor/util/xformCurrencyExchangePricing.js new file mode 100644 index 00000000000..7ce1b1c116c --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/no-meteor/util/xformCurrencyExchangePricing.js @@ -0,0 +1,56 @@ +import { toFixed } from "accounting-js"; +import Logger from "@reactioncommerce/logger"; +import getDisplayPrice from "./getDisplayPrice"; + +/** + * @name xformCurrencyExchangePricing + * @method + * @memberof GraphQL/Transforms + * @summary Converts price to the supplied currency and adds currencyExchangePricing to result + * @param {Object} pricing Original pricing object + * @param {String} currencyCode Code of currency to convert prices to + * @param {Object} context Object containing per-request state + * @returns {Object} New pricing object with converted prices + */ +export default async function xformCurrencyExchangePricing(pricing, currencyCode, context) { + const shop = await context.queries.primaryShop(context); + + if (!currencyCode) { + currencyCode = shop.currency; // eslint-disable-line no-param-reassign + } + + const currencyInfo = shop.currencies[currencyCode]; + const { rate } = currencyInfo; + + // Stop processing if we don't have a valid currency exchange rate. + // rate may be undefined if Open Exchange Rates or an equivalent service is not configured properly. + if (typeof rate !== "number") { + Logger.debug("Currency exchange rates are not available. Exchange rate fetching may not be configured."); + return null; + } + + const { compareAtPrice, price, minPrice, maxPrice } = pricing; + const priceConverted = price && Number(toFixed(price * rate, 2)); + const minPriceConverted = minPrice && Number(toFixed(minPrice * rate, 2)); + const maxPriceConverted = maxPrice && Number(toFixed(maxPrice * rate, 2)); + const displayPrice = getDisplayPrice(minPriceConverted, maxPriceConverted, currencyInfo); + let compareAtPriceConverted = null; + + if (typeof compareAtPrice === "number" && compareAtPrice > 0) { + compareAtPriceConverted = { + amount: Number(toFixed(compareAtPrice * rate, 2)), + currencyCode + }; + } + + return { + compareAtPrice: compareAtPriceConverted, + displayPrice, + price: priceConverted, + minPrice: minPriceConverted, + maxPrice: maxPriceConverted, + currency: { + code: currencyCode + } + }; +} diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/util/xformCurrencyExchangePricing.test.js b/imports/plugins/included/simple-pricing/server/no-meteor/util/xformCurrencyExchangePricing.test.js new file mode 100644 index 00000000000..65ed584a246 --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/no-meteor/util/xformCurrencyExchangePricing.test.js @@ -0,0 +1,55 @@ +import xformCurrencyExchangePricing from "./xformCurrencyExchangePricing"; + +const testShop = { + currency: "USD", + currencies: { + EUR: { + enabled: true, + format: "%v %s", + symbol: "€", + decimal: ",", + thousand: ".", + rate: 0.856467 + }, + EUR_NO_RATE: { + enabled: true, + format: "%v %s", + symbol: "€", + decimal: ",", + thousand: "." + } + } +}; + +const testContext = { + queries: { + primaryShop() { + return testShop; + } + } +}; + +const minMaxPricingInput = { + displayPrice: "$12.99 - $19.99", + maxPrice: 19.99, + minPrice: 12.99, + price: null, + currencyCode: "USD" +}; + +const minMaxPricingOutput = { + compareAtPrice: null, + displayPrice: "11,13 € - 17,12 €", + price: null, + minPrice: 11.13, + maxPrice: 17.12, + currency: { code: "EUR" } +}; + +test("xformCurrencyExchangePricing converts min-max pricing object correctly", async () => { + expect(await xformCurrencyExchangePricing(minMaxPricingInput, "EUR", testContext)).toEqual(minMaxPricingOutput); +}); + +test("xformCurrencyExchangePricing converts min-max pricing object correctly", async () => { + expect(await xformCurrencyExchangePricing(minMaxPricingInput, "EUR_NO_RATE", testContext)).toBe(null); +}); diff --git a/imports/plugins/included/simple-pricing/server/no-meteor/util/xformPricingArray.js b/imports/plugins/included/simple-pricing/server/no-meteor/util/xformPricingArray.js new file mode 100644 index 00000000000..09bbe574c82 --- /dev/null +++ b/imports/plugins/included/simple-pricing/server/no-meteor/util/xformPricingArray.js @@ -0,0 +1,8 @@ +import { assoc, compose, map, toPairs } from "ramda"; + +// add `currencyCode` keys to each pricing info object +const xformPricingEntry = ([k, v]) => compose(assoc("currencyCode", k))(v); + +// map over all provided pricing info, provided in the format stored in our Catalog collection, +// and convert them to an array +export default compose(map(xformPricingEntry), toPairs); diff --git a/lib/api/index.js b/lib/api/index.js index b7bc700bc2e..5dd7646d74a 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -1,6 +1,5 @@ export * from "./account-validation"; export { default as Catalog } from "./catalog"; export { default as ReactionProduct } from "./products"; -export { PropTypes } from "./prop-types"; export * from "./core"; export * from "./helpers"; diff --git a/lib/api/products.js b/lib/api/products.js index 46ac900a7f3..9bc55efa74b 100644 --- a/lib/api/products.js +++ b/lib/api/products.js @@ -309,51 +309,6 @@ ReactionProduct.getProductsByTag = function (tag) { return cursor; }; -/** - * @name publishProduct - * @method - * @memberof ReactionProduct - * @summary product publishing and alert - * @todo review process for publishing arrays of product - * @param {Object} productOrArray - product Object - * @returns {undefined} - returns nothing, and alerts, happen here - */ -ReactionProduct.publishProduct = function (productOrArray) { - const products = !_.isArray(productOrArray) ? [productOrArray] : productOrArray; - /* eslint no-loop-func: 1 */ - for (const product of products) { - Meteor.call("products/publishProduct", product._id, (error, result) => { - // eslint-disable-line no-loop-func - if (error) { - Alerts.add(error, "danger", { - placement: "productGridItem", - id: product._id - }); - throw new ReactionError("error-occurred", error); - } - const alertSettings = { - placement: "productGridItem", - id: product._id, - autoHide: true, - dismissable: false - }; - if (result) { - Alerts.add( - i18next.t("productDetail.publishProductVisible", { product: product.title }), - "success", - alertSettings - ); - } else { - Alerts.add( - i18next.t("productDetail.publishProductHidden", { product: product.title }), - "warning", - alertSettings - ); - } - }); - } -}; - /** * @name toggleVisibility * @method diff --git a/lib/api/prop-types.js b/lib/api/prop-types.js deleted file mode 100644 index 0b6c34682c8..00000000000 --- a/lib/api/prop-types.js +++ /dev/null @@ -1,55 +0,0 @@ -import _ from "lodash"; -import { check } from "meteor/check"; -import * as Schemas from "/lib/collections/schemas"; - -/** - * @file **PropTypes** - React Component PropTypes methods for validating Tags - * - * @namespace PropTypes - */ - -const TagSchema = Schemas.Tag.newContext(); - -export const PropTypes = {}; - -/** - * @name Tag - * @summary React Component PropTypes validator for a single Tag - * @method - * @memberof PropTypes - * @param {Object} props An object containing all props passed into the component - * @param {String} propName Name of prop to validate - * @return {Error|undefined} returns an error if validation us unseccessful - */ -PropTypes.Tag = (props, propName) => { - check(props, Object); - check(propName, String); - - if (_.isEmpty(props[propName]) === false) { - if (TagSchema.validate(props[propName]) === false) { - return new Error("Tag must be of type: Schemas.Tag"); - } - } -}; - -/** - * @name arrayOfTags - * @summary React Component PropTypes validator for an array of Tags - * @method - * @memberof PropTypes - * @param {Object} props An object containing all props passed into the component - * @param {String} propName Name of prop to validate - * @return {Error|undefined} returns an error if validation us unseccessful - */ -PropTypes.arrayOfTags = (props, propName) => { - check(props, Object); - check(propName, String); - - if (_.isEmpty(props[propName]) === false && _.isArray(props[propName])) { - const valid = _.every(props[propName], (tag) => TagSchema.validate(tag)); - - if (valid === false) { - return new Error("Objects in array must be of type: Schemas.Tag"); - } - } -}; diff --git a/private/data/i18n/en.json b/private/data/i18n/en.json index 7a1cca8c75e..58cd2a9190c 100644 --- a/private/data/i18n/en.json +++ b/private/data/i18n/en.json @@ -643,7 +643,7 @@ "signupCode": "Registration code", "signUpWithYourEmailAddress": "Register with your email address", "terms": "Terms of use", - "updateYourPassword": "Set New password", + "updateYourPassword": "Set new password", "updatePasswordAndContinue": "Update", "updatedServiceConfiguration": "Updated service configuration for {{service}}", "username": "Username", diff --git a/tests/TestApp.js b/tests/TestApp.js index 2f998f8b0ca..49003c97c9b 100644 --- a/tests/TestApp.js +++ b/tests/TestApp.js @@ -1,3 +1,4 @@ +import { merge } from "lodash"; import mongodb, { MongoClient } from "mongodb"; import MongoDBMemoryServer from "mongodb-memory-server"; import { gql } from "apollo-server"; @@ -12,13 +13,14 @@ import setUpFileCollections from "../imports/plugins/core/files/server/no-meteor import coreMediaXform from "../imports/plugins/core/files/server/no-meteor/xforms/xformFileCollectionsProductMedia"; import mutations from "../imports/node-app/devserver/mutations"; import queries from "../imports/node-app/devserver/queries"; -import schemas from "../imports/node-app/devserver/schemas"; -import resolvers from "../imports/node-app/devserver/resolvers"; +import importedSchemas from "../imports/node-app/devserver/schemas"; +import importedResolvers from "../imports/node-app/devserver/resolvers"; +import registerPlugins from "../imports/node-app/devserver/registerPlugins"; import "../imports/node-app/devserver/extendSchemas"; class TestApp { constructor(options = {}) { - const { extraSchemas = [], functionsByType = {} } = options; + const { extraSchemas = [] } = options; this.collections = {}; this.context = { @@ -31,32 +33,29 @@ class TestApp { funcs = [coreMediaXform]; break; default: - funcs = functionsByType[type] || []; + funcs = this.functionsByType[type] || []; } return funcs; }, - mutations, - queries + mutations: { ...mutations }, + queries: { ...queries } }; - const { apolloServer, expressApp } = createApolloServer({ - addCallMeteorMethod(context) { - context.callMeteorMethod = (name) => { - console.warn(`The "${name}" Meteor method was called. The method has not yet been converted to a mutation that` + // eslint-disable-line no-console - " works outside of Meteor. If you are relying on a side effect or return value from this method, you may notice unexpected behavior."); - return null; - }; - }, - context: this.context, - schemas: [...schemas, ...extraSchemas], - resolvers - // Uncomment this if you need to debug a test. Otherwise we keep debug mode off to avoid extra - // error logging in the test output. - // debug: true - }); + this.functionsByType = {}; + this.graphQL = { + resolvers: { ...importedResolvers }, + schemas: [...importedSchemas, ...extraSchemas] + }; + this.registeredPlugins = {}; - this.app = expressApp; - this.graphClient = createTestClient(apolloServer); + if (options.functionsByType) { + Object.keys(options.functionsByType).forEach((type) => { + if (!Array.isArray(this.functionsByType[type])) { + this.functionsByType[type] = []; + } + this.functionsByType[type].push(...options.functionsByType[type]); + }); + } } mutate = (mutation) => async (variables) => { @@ -133,6 +132,7 @@ class TestApp { symbol: "$" } }, + currency: "USD", name: "Primary Shop", ...shopData, domains: [domain] @@ -143,6 +143,68 @@ class TestApp { return result.insertedId; } + // Keep this in sync with the real `registerPlugin` in `ReactionNodeApp` + async registerPlugin(plugin) { + if (typeof plugin.name !== "string" || plugin.name.length === 0) { + throw new Error("Plugin configuration passed to registerPlugin must have 'name' field"); + } + + if (this.registeredPlugins[plugin.name]) { + throw new Error(`You registered multiple plugins with the name "${plugin.name}"`); + } + + this.registeredPlugins[plugin.name] = plugin; + + if (plugin.graphQL) { + if (plugin.graphQL.resolvers) { + merge(this.graphQL.resolvers, plugin.graphQL.resolvers); + } + if (plugin.graphQL.schemas) { + this.graphQL.schemas.push(...plugin.graphQL.schemas); + } + } + + if (plugin.mutations) { + merge(this.context.mutations, plugin.mutations); + } + + if (plugin.queries) { + merge(this.context.queries, plugin.queries); + } + + if (plugin.functionsByType) { + Object.keys(plugin.functionsByType).forEach((type) => { + if (!Array.isArray(this.functionsByType[type])) { + this.functionsByType[type] = []; + } + this.functionsByType[type].push(...plugin.functionsByType[type]); + }); + } + } + + initServer() { + const { resolvers, schemas } = this.graphQL; + + const { apolloServer, expressApp } = createApolloServer({ + addCallMeteorMethod(context) { + context.callMeteorMethod = (name) => { + console.warn(`The "${name}" Meteor method was called. The method has not yet been converted to a mutation that` + // eslint-disable-line no-console + " works outside of Meteor. If you are relying on a side effect or return value from this method, you may notice unexpected behavior."); + return null; + }; + }, + context: this.context, + schemas, + resolvers + // Uncomment this if you need to debug a test. Otherwise we keep debug mode off to avoid extra + // error logging in the test output. + // debug: true + }); + + this.app = expressApp; + this.graphClient = createTestClient(apolloServer); + } + async startMongo() { this.mongoServer = new MongoDBMemoryServer(); const mongoUri = await this.mongoServer.getConnectionString(); @@ -168,6 +230,8 @@ class TestApp { } async start() { + await registerPlugins(this); + this.initServer(); await this.startMongo(); } diff --git a/tests/catalog/PublishProductsToCatalogMutation.graphql b/tests/catalog/PublishProductsToCatalogMutation.graphql index 00bd99390e7..0c0a4c34193 100644 --- a/tests/catalog/PublishProductsToCatalogMutation.graphql +++ b/tests/catalog/PublishProductsToCatalogMutation.graphql @@ -7,11 +7,9 @@ mutation ($productIds: [ID]!) { supportedFulfillmentTypes variants { _id - price title options { _id - price title } } diff --git a/tests/catalog/publishProductsToCatalog.test.js b/tests/catalog/publishProductsToCatalog.test.js index c1e5a2ee5c8..6c012d0291e 100644 --- a/tests/catalog/publishProductsToCatalog.test.js +++ b/tests/catalog/publishProductsToCatalog.test.js @@ -30,7 +30,6 @@ const mockProduct = { const mockVariant = { _id: internalVariantIds[0], ancestors: [internalProductId], - price: 2.99, title: "Fake Product Variant", shopId: internalShopId, isDeleted: false, @@ -40,7 +39,6 @@ const mockVariant = { const mockOptionOne = { _id: internalVariantIds[1], ancestors: [internalProductId, internalVariantIds[0]], - price: 2.99, title: "Fake Product Option One", shopId: internalShopId, isDeleted: false, @@ -50,7 +48,6 @@ const mockOptionOne = { const mockOptionTwo = { _id: internalVariantIds[2], ancestors: [internalProductId, internalVariantIds[0]], - price: 2.99, title: "Fake Product Option Two", shopId: internalShopId, isDeleted: false, @@ -67,16 +64,13 @@ const mockCatalogItem = { { _id: opaqueCatalogVariantIds[0], title: "Fake Product Variant", - price: 2.99, options: [ { _id: opaqueCatalogVariantIds[1], - price: 2.99, title: "Fake Product Option One" }, { _id: opaqueCatalogVariantIds[2], - price: 2.99, title: "Fake Product Option Two" } ] diff --git a/tests/mocks/mockCatalogProducts.js b/tests/mocks/mockCatalogProducts.js index e051f6bf901..d5e4749b130 100644 --- a/tests/mocks/mockCatalogProducts.js +++ b/tests/mocks/mockCatalogProducts.js @@ -61,7 +61,6 @@ export const mockInternalCatalogOptions = [ minOrderQuantity: 0, optionTitle: "Awesome Soft Bike", originCountry: "US", - price: 5.99, pricing: { USD: { compareAtPrice: null, @@ -107,7 +106,6 @@ export const mockInternalCatalogOptions = [ minOrderQuantity: 0, optionTitle: "Another Awesome Soft Bike", originCountry: "US", - price: 2.99, pricing: { USD: { compareAtPrice: null, @@ -268,7 +266,6 @@ export const mockInternalCatalogVariants = [ options: mockInternalCatalogOptions, optionTitle: "Untitled Option", originCountry: "US", - price: 0, pricing: { USD: { compareAtPrice: 10, @@ -384,11 +381,6 @@ export const mockInternalCatalogProducts = [ height: 6.66, weight: 7.77 }, - price: { - max: 5.99, - min: 2.99, - range: "2.99 - 5.99" - }, pricing: { USD: { compareAtPrice: 10, @@ -480,11 +472,6 @@ export const mockInternalCatalogProducts = [ height: 6.66, weight: 7.77 }, - price: { - max: 25.99, - min: 16.99, - range: "16.99 - 25.99" - }, pricing: { USD: { compareAtPrice: 35, diff --git a/tests/order/addOrderFulfillmentGroup.test.js b/tests/order/addOrderFulfillmentGroup.test.js index b64763bedd7..797badb16f7 100644 --- a/tests/order/addOrderFulfillmentGroup.test.js +++ b/tests/order/addOrderFulfillmentGroup.test.js @@ -16,6 +16,8 @@ let catalogItem2; let mockOrdersAccount; let shopId; +const variant1Price = 10; +const variant2Price = 5; const fulfillmentMethodId = "METHOD_ID"; const mockShipmentMethod = { _id: fulfillmentMethodId, @@ -71,7 +73,11 @@ beforeAll(async () => { variants: Factory.CatalogVariantSchema.makeMany(1, { _id: Random.id(), options: null, - price: 10, + pricing: { + USD: { + price: variant1Price + } + }, variantId: Random.id() }) }) @@ -89,7 +95,11 @@ beforeAll(async () => { variants: Factory.CatalogVariantSchema.makeMany(1, { _id: Random.id(), options: null, - price: 5, + pricing: { + USD: { + price: variant2Price + } + }, variantId: Random.id() }) }) @@ -100,8 +110,8 @@ beforeAll(async () => { }); afterAll(async () => { - await testApp.collections.Catalog.remove({}); - await testApp.collections.Shops.remove({}); + await testApp.collections.Catalog.deleteMany({}); + await testApp.collections.Shops.deleteMany({}); testApp.stop(); }); @@ -110,7 +120,7 @@ test("user with orders role can add an order fulfillment group with new items", const orderItem = Factory.OrderItem.makeOne({ price: { - amount: catalogItem.product.variants[0].price, + amount: variant1Price, currencyCode: "USD" }, productId: catalogItem.product.productId, @@ -130,6 +140,7 @@ test("user with orders role can add an order fulfillment group with new items", const order = Factory.Order.makeOne({ accountId: "123", + currencyCode: "USD", shipping: [group], shopId, workflow: { @@ -144,7 +155,7 @@ test("user with orders role can add an order fulfillment group with new items", result = await addOrderFulfillmentGroup({ fulfillmentGroup: { items: [{ - price: catalogItem2.product.variants[0].price, + price: variant2Price, productConfiguration: { productId: encodeProductOpaqueId(catalogItem2.product.productId), productVariantId: encodeProductOpaqueId(catalogItem2.product.variants[0].variantId) @@ -192,7 +203,7 @@ test("user with orders role can add an order fulfillment group with new items", nodes: [ { price: { - amount: catalogItem2.product.variants[0].price + amount: variant2Price }, productConfiguration: { productId: encodeProductOpaqueId(catalogItem2.product.productId), @@ -219,7 +230,7 @@ test("user with orders role can add an order fulfillment group with moved items" const orderItemToStay = Factory.OrderItem.makeOne({ price: { - amount: catalogItem.product.variants[0].price, + amount: variant1Price, currencyCode: "USD" }, productId: catalogItem.product.productId, @@ -233,7 +244,7 @@ test("user with orders role can add an order fulfillment group with moved items" const orderItemToMove = Factory.OrderItem.makeOne({ price: { - amount: catalogItem2.product.variants[0].price, + amount: variant2Price, currencyCode: "USD" }, quantity: 10, @@ -259,6 +270,7 @@ test("user with orders role can add an order fulfillment group with moved items" const order = Factory.Order.makeOne({ accountId: "123", + currencyCode: "USD", shipping: [group], shopId, workflow: { @@ -314,7 +326,7 @@ test("user with orders role can add an order fulfillment group with moved items" nodes: [ { price: { - amount: catalogItem2.product.variants[0].price + amount: variant2Price }, productConfiguration: { productId: encodeProductOpaqueId(catalogItem2.product.productId), diff --git a/tests/order/cancelOrderItem.test.js b/tests/order/cancelOrderItem.test.js index bd65b70e32a..60f75051e2d 100644 --- a/tests/order/cancelOrderItem.test.js +++ b/tests/order/cancelOrderItem.test.js @@ -27,8 +27,8 @@ beforeAll(async () => { }); afterAll(async () => { - await testApp.collections.Catalog.remove({}); - await testApp.collections.Shops.remove({}); + await testApp.collections.Catalog.deleteMany({}); + await testApp.collections.Shops.deleteMany({}); testApp.stop(); }); diff --git a/tests/order/moveOrderItems.test.js b/tests/order/moveOrderItems.test.js index a93d0d1894c..ff5a6dd7abc 100644 --- a/tests/order/moveOrderItems.test.js +++ b/tests/order/moveOrderItems.test.js @@ -58,8 +58,8 @@ beforeAll(async () => { }); afterAll(async () => { - await testApp.collections.Catalog.remove({}); - await testApp.collections.Shops.remove({}); + await testApp.collections.Catalog.deleteMany({}); + await testApp.collections.Shops.deleteMany({}); testApp.stop(); }); diff --git a/tests/order/splitOrderItem.test.js b/tests/order/splitOrderItem.test.js index bef103af830..796496d6170 100644 --- a/tests/order/splitOrderItem.test.js +++ b/tests/order/splitOrderItem.test.js @@ -69,8 +69,8 @@ beforeAll(async () => { }); afterAll(async () => { - await testApp.collections.Catalog.remove({}); - await testApp.collections.Shops.remove({}); + await testApp.collections.Catalog.deleteMany({}); + await testApp.collections.Shops.deleteMany({}); testApp.stop(); }); diff --git a/tests/order/updateOrder.test.js b/tests/order/updateOrder.test.js index 08a50b17b0a..6c27ea760b4 100644 --- a/tests/order/updateOrder.test.js +++ b/tests/order/updateOrder.test.js @@ -36,8 +36,8 @@ beforeAll(async () => { }); afterAll(async () => { - await testApp.collections.Catalog.remove({}); - await testApp.collections.Shops.remove({}); + await testApp.collections.Catalog.deleteMany({}); + await testApp.collections.Shops.deleteMany({}); testApp.stop(); }); diff --git a/tests/order/updateOrderFulfillmentGroup.test.js b/tests/order/updateOrderFulfillmentGroup.test.js index a232e3156ce..5d0706f1ace 100644 --- a/tests/order/updateOrderFulfillmentGroup.test.js +++ b/tests/order/updateOrderFulfillmentGroup.test.js @@ -36,8 +36,8 @@ beforeAll(async () => { }); afterAll(async () => { - await testApp.collections.Catalog.remove({}); - await testApp.collections.Shops.remove({}); + await testApp.collections.Catalog.deleteMany({}); + await testApp.collections.Shops.deleteMany({}); testApp.stop(); });