diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000000..dba04c1e178 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +8.11.3 diff --git a/.reaction/waitForReplica.js b/.reaction/waitForMongo.js similarity index 81% rename from .reaction/waitForReplica.js rename to .reaction/waitForMongo.js index c6ebe728af9..2e9a22095ac 100644 --- a/.reaction/waitForReplica.js +++ b/.reaction/waitForMongo.js @@ -10,7 +10,8 @@ function defaultOut(message) { } /** - * Print a message to the console (no trailing newline) + * Run a check/wait/retry loop until a provided function returns a + * promise that resolves. * @param {Object} options - Named options object * @param {function()} options.out Function to show progress * @param {number} options.max Number of retries attempted before full failure @@ -34,8 +35,8 @@ async function checkWaitRetry({ * * @param {string} message to be printed * @param {number} count retry number for progress dots - * @returns {undefined} - */ + * @returns {undefined} + */ function showOnce(message, count) { if (!messages.has(message)) { messages.add(message); @@ -87,6 +88,29 @@ async function connect(mongoUrl) { return client.db(dbName); } +/** + * Runs the mongo command replSetInitiate, + * which we need for the oplog for meteor real-time + * + * @param {objecct} db connected mongo db instance + * @returns {Promise} indication of success/failure + */ +async function initReplicaSet(db) { + try { + await db.admin().command({ + replSetInitiate: { + _id: "rs0", + members: [{ _id: 0, host: "localhost:27017" }] + } + }); + } catch (error) { + // AlreadyInitialized is OK to treat as success + if (error.codeName !== "AlreadyInitialized") { + throw error; + } + } +} + /** * Check if replication is ready * @@ -109,7 +133,11 @@ async function main() { if (!MONGO_URL) { throw new Error("You must set MONGO_URL environment variable."); } - const db = await connect(MONGO_URL); + const db = await checkWaitRetry({ + timeoutMessage: "ERROR: MongoDB not reachable in time.", + check: connect.bind(null, MONGO_URL) + }); + await initReplicaSet(db); await checkWaitRetry({ timeoutMessage: "ERROR: MongoDB replica set not ready in time.", check: checkReplicaSetStatus.bind(null, db) @@ -132,11 +160,7 @@ function exit(error) { // Allow this module to be run directly as a node program or imported as lib if (require.main === module) { process.on("unhandledRejection", exit); - try { - main(); - } catch (error) { - exit(error); - } + main().catch(exit); } module.exports = { diff --git a/CHANGELOG.md b/CHANGELOG.md index 110ab070201..4e256c2b0b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,89 @@ +# v2.0.0-rc.5 +This is our fifth **release candidate** for v2.0.0 of Reaction. Please check it out and let us know what works and what doesn't for you. + +## Mongo replica set issue +Many people were having issues with the Mongo replica-set image starting before the Mongo database was ready. This could cause the replica-set to fail and the application to hang during startup in a development environment. This is fixed in #4748 by waiting for mongo to be reachable within the reaction container before connecting to it, and creating the DB if needed, initiating the replica set if needed, and waiting for the replica set to be OK. This fix should solve the docker-compose startup race conditions we've been seeing. (#4748) + +## GraphQL +We've added two new GraphQL queries for payment methods. A query `paymentMethods` which will list all registered payment methods and is restricted to operators and `availablePaymentMethods` which will list all payment methods which have been enabled. These new queries were added in #4709. We've also added a GraphQL mutation that permits an operator to enable or disable a payment method for a shop in #4739 + +We've updated the CartItems and OrderItems GraphQL queries to include a `productTags` resolver which will return the tags for the CartItem or OrderItem. The new resolvers and updated schemas were added in #4715 and #4732 + +There is a new GraphQL mutation for generating sitemaps `generateSitemaps` this replaces the `sitemaps/generate` meteor method. method. (#4708) + +## Classic Storefront UI Updates +We've replaced the customer facing Product Grid in the Classic Storefront UI with our [CatalogGrid](https://designsystem.reactioncommerce.com/#!/CatalogGrid) component from the Reaction Design System. This was accomplished in #4649 + +There's a new "Include in sitemap?" checkbox in the Product Settings when using the operator interface to edit product information. This was added to make it possible to exclude published products from the sitemap. (#4708) + +## Additional Plugin Capabilities +A plugin can now include a `catalog` object in `registerPackage`, with `customPublishedProductFields` and `customPublishedProductVariantFields` that are set to arrays of property names. These will be appended to the core list of fields for which published status should be tracked. This is used to build the hashes that are used to display an indicator when changes need to be published. (#4738) + +A plugin can now use the `functionsByType` pattern to register one or more functions of type "publishProductToCatalog", which are called with `(catalogProduct, { context, product, shop, variants })` and expected to mutate `catalogProduct` if necessary. (#4738) + + +## nvmrc +Even though most of the development work happens in Docker, getting the right version of node available directly in the host OS is convenient for setting up eslint integration with your editor. We've added an `.nvmrc` file for this as [we've recommended](https://docs.reactioncommerce.com/docs/recommended-tools#general) `nvm` for installing and managing NodeJS in our docs for some time now. + + +## Public API Changes +We've changed the GraphQL schema for `PaymentMethod@name` from `PaymentMethodName` to `String`. `PaymentMethodName` was a subset of string and this should not cause any issues. + +## Breaking Changes +WE've replaced the `generateSitemaps` Meteor method with a GraphQL mutation. See #4708 for details. + +Because we've replaced the customer facing Product Grid UI in the Classic Storefront UI, if you had any plugins which relied on specific selectors or the structure of the existing UI, those may need to be updated. + + +## Features + - feat: payment methods (#4709) .. Resolves #4574 + - feat: enable payment method for shop (#4739) .. Resolves #4718 + - feat: use component library's CatalogGrid - 2.0 (#4649) + - feat: add product tags to cart items (#4715) + - feat: Add product tags to order item (#4732) + - feat: option to choose whether a product should appear in the sitemap (#4708) + - feat: add a way to extend catalog product publication (#4738) + + +## Fixes + - fix: Auth Consent scopes issue (#4733) + - fix: 4722 compareAtPrice - convert from Float to Money (#4731) + - fix(startup): init mongo replica set after waiting for connection (#4748) + +## Chores + - chore: add .nvmrc configuration file (#4744) + +## Docs + - docs: Link readers to Reaction Platform install instructions (#4724) + - docs: fix jsdoc copypasta on waitForReplica checkWaitRetry (#4723) + + +# v2.0.0-rc.4 +This is our fourth **release candidate** for v2.0.0 of Reaction. Please check it out and let us know what works and what doesn't for you. + +## Improving Jest test performance in CI +We started seeing unit tests timing out in CI in the morning on Friday October 5. It doesn't appear that this was caused by a change in our `jest` version as we were able to reproduce the issues on older branches which were previously passing. +This is resolved in #4176 by changing our `test:unit` script in `package.json` to run jest with the `--maxWorkers=4` flag. This resolved our issue with tests timing out, and improves test performance in CI overall. This is suggested in the troubleshooting jest here: https://jestjs.io/docs/en/troubleshooting.html#tests-are-extremely-slow-on-docker-and-or-continuous-integration-ci-server + +## Checkout Totals +There were some cases in the Classic Storefront UI where there would be a discrepancy between the total calculated on the server and the price calculated by the client. +This is not an issue in the [Next.js Storefront](https://github.com/reactioncommerce/reaction-next-starterkit) as all price values are calculated on the server. This is resolved in #4701 + +## Bugfixes +fix: round total when verifying it on order create (#4701) .. Resolves #4684 + +## Chores +fix: limit jest maxWorkers to 4 to improve CI perf (#4716) + +# v2.0.0-rc.3 +This is our third **release candidate** for v2.0.0 of Reaction. Please check it out and let us know what works and what doesn't for you. + +A few files snuck into our last release that had incorrect jsdoc syntax in the form of `@return Type` +The jsdoc parser is unable to parse any return type starting with a `<` and throws an error. This error is thrown during the Deploy Docs CI step and causes that step of the CI to fail. This is resolved in #4704 by fixing the jsdoc to use the correct Promise syntax `@return Promise` + +## Bugfixes +- fix: resolve errors in jsdoc Promise returns (#4704) + # v2.0.0-rc.2 This is our second **release candidate** for v2.0.0 of Reaction. Please check it out and let us know what works and what doesn't for you. diff --git a/README.md b/README.md index 5dc83ee8094..c9673a4d4f1 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ [![Circle CI](https://circleci.com/gh/reactioncommerce/reaction.svg?style=svg)](https://circleci.com/gh/reactioncommerce/reaction) [![Gitter](https://badges.gitter.im/JoinChat.svg)](https://gitter.im/reactioncommerce/reaction?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Open Source Helpers](https://www.codetriage.com/reactioncommerce/reaction/badges/users.svg)](https://www.codetriage.com/reactioncommerce/reaction) - [Reaction](http://reactioncommerce.com) is an event-driven, real-time reactive commerce platform built with JavaScript (ES6). It plays nicely with npm, Docker, and React. ![Reaction v.1.x](https://raw.githubusercontent.com/reactioncommerce/reaction-docs/v1.7.0/assets/Reaction-Commerce-Illustration-BG-800px.png) @@ -12,7 +11,7 @@ Reaction’s out-of-the-box core features include: -- One step cart and checkout +- One-step cart and checkout - Order processing - Payments with Stripe - Shipping @@ -26,52 +25,16 @@ Since anything in our codebase can be extended, overwritten, or installed as a p # Getting started -### Requirements - -Reaction requires Meteor, Git, Mongo DB, OS-specific build tools. For step-by-step instructions, follow the documentation for [OS X](https://docs.reactioncommerce.com/docs/next/installation-osx), [Windows](https://docs.reactioncommerce.com/docs/next/installation-windows) or [Linux](https://docs.reactioncommerce.com/docs/next/installation-linux). - -### Install and run with CLI - -Install the [Reaction CLI](https://github.com/reactioncommerce/reaction-cli) to get started with Reaction: - -```sh -npm install -g reaction-cli -``` - -Create your store: - -```sh -reaction init -cd reaction -reaction -``` - -Open `localhost:3000` - -Learn more on how to [configure your project](https://docs.reactioncommerce.com/reaction-docs/master/configuration). - -Having installation issues? Check out our [troubleshooting docs](https://docs.reactioncommerce.com/docs/next/troubleshooting-development). - -### Install and run with Docker - -You can also run the app locally using [`docker-compose`](https://docs.docker.com/compose/) by running: - -```sh -docker network create api.reaction.localhost -docker-compose up -``` - -Open `localhost:3000` - -This will use the `docker-compose.yml` file. +Follow the documentation to install Reaction with [Reaction Platform](https://docs.reactioncommerce.com/docs/installation-reaction-platform) for all operating systems. -To learn more on how to develop on Docker, read our documentation on [developing Reaction on Docker](https://docs.reactioncommerce.com/docs/next/installation-docker-development) and [troubleshooting Docker](https://docs.reactioncommerce.com/docs/next/troubleshooting-development#docker-issues). +> Installing an older version of Reaction? Follow the documentation for installing pre-2.0 Reaction on [OS X](https://docs.reactioncommerce.com/docs/1.16.0/installation-osx), [Windows](https://docs.reactioncommerce.com/docs/1.16.0/installation-windows) or [Linux](https://docs.reactioncommerce.com/docs/1.16.0/installation-linux). # Get involved ## Read documentation & tutorials - [Reaction Commerce: Developer documentation](https://docs.reactioncommerce.com) +- [Reaction Design System](http://designsystem.reactioncommerce.com/) - [Reaction Commerce: API documentation](http://api.docs.reactioncommerce.com) - [Reaction Commerce engineering blog posts](https://blog.reactioncommerce.com/tag/engineering/) - [Reaction Commerce YouTube videos](https://www.youtube.com/user/reactioncommerce/videos) @@ -86,13 +49,16 @@ To learn more on how to develop on Docker, read our documentation on [developing :star: Star us on GitHub — it helps! -Want to request a feature? Use our Reaction Feature Requests repository to file a request. +Want to request a feature? Use our [Reaction Feature Requests repository](https://github.com/reactioncommerce/reaction-feature-requests) to file a request. We love your pull requests! Check our our [`Good First Issue`](https://github.com/reactioncommerce/reaction/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) and [`Help Wanted`](https://github.com/reactioncommerce/reaction/issues?q=label%3A%22help+wanted%22) tags for good issues to tackle. Pull requests should: -- Pass all pull request Cirlce CI checks: Run `npm run lint` and `reaction test` to make sure you're following the [Reaction Code Style Guide](https://docs.reactioncommerce.com/reaction-docs/master/styleguide) and passing [acceptance tests and unit tests](https://docs.reactioncommerce.com/reaction-docs/master/testing-reaction). +- Pass all Circle CI checks: + - Run `docker-compose run --rm reaction npm run lint` to make sure your code follows [Reaction's ESLint rules](https://github.com/reactioncommerce/reaction-eslint-config). + - Run `docker-compose run --rm reaction reaction test` to run [acceptance tests and unit tests](https://docs.reactioncommerce.com/reaction-docs/master/testing-reaction). + - Make sure you're following the [Reaction Code Style Guide](https://docs.reactioncommerce.com/reaction-docs/master/styleguide) and - Follow the pull request template. Get more details in our [Contributing Guide](https://docs.reactioncommerce.com/reaction-docs/master/contributing-to-reaction). diff --git a/docker-compose.yml b/docker-compose.yml index b289145e97f..1da68726aad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,9 +18,9 @@ services: build: context: . target: meteor-dev - command: bash -c "npm install && node ./.reaction/waitForReplica.js && reaction" #TODO; Revert to Meteor NPM. See comment in Dockerfile about Meteor1.7 NPM version issue. + command: bash -c "npm install && node ./.reaction/waitForMongo.js && reaction" #TODO; Revert to Meteor NPM. See comment in Dockerfile about Meteor1.7 NPM version issue. depends_on: - - mongo-init-replica + - mongo environment: MONGO_URL: "mongodb://mongo:27017/reaction" MONGO_OPLOG_URL: "mongodb://mongo:27017/local" @@ -45,13 +45,6 @@ services: volumes: - mongo-db:/data/db - # This container's job is just to run the command to initialize the replica set. It will stop after doing that. - mongo-init-replica: - image: mongo:3.6.3 - command: 'mongo mongo/reaction --eval "rs.initiate({ _id: ''rs0'', members: [ { _id: 0, host: ''localhost:27017'' } ]})"' - depends_on: - - mongo - volumes: mongo-db: reaction_node_modules: diff --git a/imports/collections/schemas/cart.js b/imports/collections/schemas/cart.js index 82b2125d3cc..023e24796f7 100644 --- a/imports/collections/schemas/cart.js +++ b/imports/collections/schemas/cart.js @@ -97,6 +97,12 @@ export const CartItem = new SimpleSchema({ type: String, optional: true }, + "productTagIds": { + label: "Product Tags", + type: Array, + optional: true + }, + "productTagIds.$": String, "productVendor": { label: "Product Vendor", type: String, diff --git a/imports/collections/schemas/catalog.js b/imports/collections/schemas/catalog.js index bdb1f0e0a3c..9f49f2a7ccf 100644 --- a/imports/collections/schemas/catalog.js +++ b/imports/collections/schemas/catalog.js @@ -314,7 +314,7 @@ export const VariantBaseSchema = new SimpleSchema({ * @extends VariantBaseSchema * @property {VariantBaseSchema[]} options optional */ -const CatalogVariantSchema = VariantBaseSchema.clone().extend({ +export const CatalogVariantSchema = VariantBaseSchema.clone().extend({ "options": { type: Array, label: "Variant Options", diff --git a/imports/collections/schemas/orders.js b/imports/collections/schemas/orders.js index daa8a4df521..3f25c5cca0a 100644 --- a/imports/collections/schemas/orders.js +++ b/imports/collections/schemas/orders.js @@ -235,6 +235,12 @@ export const OrderItem = new SimpleSchema({ type: String, optional: true }, + "productTagIds": { + label: "Product Tags", + type: Array, + optional: true + }, + "productTagIds.$": String, "productVendor": { label: "Product Vendor", type: String, diff --git a/imports/collections/schemas/products.js b/imports/collections/schemas/products.js index 00f37bf9816..33416beb880 100644 --- a/imports/collections/schemas/products.js +++ b/imports/collections/schemas/products.js @@ -45,81 +45,43 @@ export const VariantMedia = new SimpleSchema({ registerSchema("VariantMedia", VariantMedia); -/** - * @name ProductPosition - * @memberof Schemas - * @type {SimpleSchema} - * @property {String} tag optional - * @property {Number} position optional - * @property {Boolean} pinned optional - * @property {Number} weight optional, default value: `0` - * @property {Date} updatedAt required - */ -export const ProductPosition = new SimpleSchema({ - tag: { - type: String, - optional: true - }, - position: { - type: SimpleSchema.Integer, - optional: true - }, - pinned: { - type: Boolean, - optional: true - }, - weight: { - type: SimpleSchema.Integer, - optional: true, - defaultValue: 0, - min: 0, - max: 3 - }, - updatedAt: { - type: Date - } -}); - -registerSchema("ProductPosition", ProductPosition); - /** * @name ProductVariant * @memberof Schemas * @type {SimpleSchema} * @property {String} _id required, Variant ID * @property {String[]} ancestors, default value: `[]` - * @property {Number} index optional, Variant position number in list. Keep array index for moving variants in a list. - * @property {Boolean} isVisible, default value: `false` - * @property {Boolean} isDeleted, default value: `false` * @property {String} barcode optional * @property {Number} compareAtPrice optional, Compare at price - * @property {String} fulfillmentService optional, Fulfillment service - * @property {Number} weight, default value: `0` - * @property {Number} length optional, default value: `0` - * @property {Number} width optional, default value: `0` + * @property {Date} createdAt optional + * @property {Event[]} eventLog optional, Variant Event Log * @property {Number} height optional, default value: `0` + * @property {Number} index optional, Variant position number in list. Keep array index for moving variants in a list. * @property {Boolean} inventoryManagement, default value: `true` * @property {Boolean} inventoryPolicy, default value: `false`, If disabled, item can be sold even if it not in stock. - * @property {Number} lowInventoryWarningThreshold, default value: `0`, Warn of low inventory at this number * @property {Number} inventoryQuantity, default value: `0` - * @property {Number} minOrderQuantity optional + * @property {Boolean} isDeleted, default value: `false` * @property {Boolean} isLowQuantity optional, true when at least 1 variant is below `lowInventoryWarningThreshold` * @property {Boolean} isSoldOut optional, denormalized field, indicates when all variants `inventoryQuantity` is 0 + * @property {Boolean} isVisible, default value: `false` + * @property {Number} length optional, default value: `0` + * @property {Number} lowInventoryWarningThreshold, default value: `0`, Warn of low inventory at this number + * @property {Metafield[]} metafields optional + * @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} type, default value: `"variant"` * @property {Boolean} taxable, default value: `true` * @property {String} taxCode, default value: `"0000"` * @property {String} taxDescription optional * @property {String} title, Label for customers, default value: `""` - * @property {String} optionTitle, Option internal name, default value: `"Untitled option"` - * @property {Metafield[]} metafields optional - * @property {Date} createdAt optional + * @property {String} type, default value: `"variant"` * @property {Date} updatedAt optional - * @property {Event[]} eventLog optional, Variant Event Log + * @property {Number} weight, default value: `0` + * @property {Number} width optional, default value: `0` * @property {Workflow} workflow optional - * @property {String} originCountry optional */ export const ProductVariant = new SimpleSchema({ "_id": { @@ -133,21 +95,6 @@ export const ProductVariant = new SimpleSchema({ "ancestors.$": { type: String }, - "index": { - label: "Variant position number in list", - type: SimpleSchema.Integer, - optional: true - }, - "isVisible": { - type: Boolean, - index: 1, - defaultValue: false - }, - "isDeleted": { - type: Boolean, - index: 1, - defaultValue: false - }, "barcode": { label: "Barcode", type: String, @@ -167,34 +114,21 @@ export const ProductVariant = new SimpleSchema({ min: 0, defaultValue: 0.00 }, - "weight": { - label: "Weight", - type: Number, - min: 0, - optional: true, - defaultValue: 0, - custom() { - if (Meteor.isClient) { - if (!(this.siblingField("type").value === "inventory" || this.value || - this.value === 0)) { - return SimpleSchema.ErrorTypes.REQUIRED; - } - } - } + "createdAt": { + label: "Created at", + type: Date, + optional: true }, - "length": { - label: "Length", - type: Number, - min: 0, - optional: true, - defaultValue: 0 + // TODO: REVIEW - Does this need to exist? Should we use workflow instead? + // Should it be called 'history' or something else instead? + // Should this go into the Logger instead? Is the logger robust enough for this? + "eventLog": { + label: "Variant Event Log", + type: Array, + optional: true }, - "width": { - label: "Width", - type: Number, - min: 0, - optional: true, - defaultValue: 0 + "eventLog.$": { + type: Event }, "height": { label: "Height", @@ -203,6 +137,11 @@ export const ProductVariant = new SimpleSchema({ optional: true, defaultValue: 0 }, + "index": { + label: "Variant position number in list", + type: SimpleSchema.Integer, + optional: true + }, "inventoryManagement": { type: Boolean, label: "Inventory Tracking", @@ -231,23 +170,16 @@ export const ProductVariant = new SimpleSchema({ } } }, - "lowInventoryWarningThreshold": { - type: SimpleSchema.Integer, - label: "Warn at", - min: 0, - optional: true, - defaultValue: 0 - }, "inventoryQuantity": { type: SimpleSchema.Integer, label: "Quantity", optional: true, defaultValue: 0 }, - "minOrderQuantity": { - label: "Minimum order quantity", - type: SimpleSchema.Integer, - optional: true + "isDeleted": { + type: Boolean, + index: 1, + defaultValue: false }, "isLowQuantity": { label: "Indicates that the product quantity is too low", @@ -259,6 +191,47 @@ export const ProductVariant = new SimpleSchema({ type: Boolean, optional: true }, + "isVisible": { + type: Boolean, + index: 1, + defaultValue: false + }, + "length": { + label: "Length", + type: Number, + min: 0, + optional: true, + defaultValue: 0 + }, + "lowInventoryWarningThreshold": { + type: SimpleSchema.Integer, + label: "Warn at", + min: 0, + optional: true, + defaultValue: 0 + }, + "metafields": { + type: Array, + optional: true + }, + "metafields.$": { + type: Metafield + }, + "minOrderQuantity": { + label: "Minimum order quantity", + type: SimpleSchema.Integer, + optional: true + }, + "optionTitle": { + label: "Option", + type: String, + optional: true, + defaultValue: "Untitled Option" + }, + "originCountry": { + type: String, + optional: true + }, "price": { label: "Price", type: Number, @@ -276,11 +249,6 @@ export const ProductVariant = new SimpleSchema({ type: String, optional: true }, - "type": { - label: "Type", - type: String, - defaultValue: "variant" - }, "taxable": { label: "Taxable", type: Boolean, @@ -303,48 +271,42 @@ export const ProductVariant = new SimpleSchema({ type: String, defaultValue: "" }, - "optionTitle": { - label: "Option", + "type": { + label: "Type", type: String, - optional: true, - defaultValue: "Untitled Option" - }, - "metafields": { - type: Array, - optional: true - }, - "metafields.$": { - type: Metafield - }, - "createdAt": { - label: "Created at", - type: Date, - optional: true + defaultValue: "variant" }, "updatedAt": { label: "Updated at", type: Date, optional: true }, - // TODO: REVIEW - Does this need to exist? Should we use workflow instead? - // Should it be called 'history' or something else instead? - // Should this go into the Logger instead? Is the logger robust enough for this? - "eventLog": { - label: "Variant Event Log", - type: Array, - optional: true + "weight": { + label: "Weight", + type: Number, + min: 0, + optional: true, + defaultValue: 0, + custom() { + if (Meteor.isClient) { + if (!(this.siblingField("type").value === "inventory" || this.value || + this.value === 0)) { + return SimpleSchema.ErrorTypes.REQUIRED; + } + } + } }, - "eventLog.$": { - type: Event + "width": { + label: "Width", + type: Number, + min: 0, + optional: true, + defaultValue: 0 }, "workflow": { type: Workflow, optional: true, defaultValue: {} - }, - "originCountry": { - type: String, - optional: true } }); @@ -383,42 +345,43 @@ registerSchema("PriceRange", PriceRange); * @memberof Schemas * @property {String} _id Product ID * @property {String[]} ancestors default value: `[]` - * @property {String} shopId Product ShopID - * @property {String} title Product Title - * @property {String} pageTitle optional + * @property {Date} createdAt required + * @property {String} currentProductHash optional * @property {String} description optional - * @property {String} productType optional - * @property {String} originCountry optional - * @property {String} type default value: `"simple"` - * @property {String} vendor optional - * @property {Metafield[]} metafields optional - * @property {PriceRange} price denormalized, object with range string, min and max - * @property {Boolean} isLowQuantity denormalized, true when at least 1 variant is below `lowInventoryWarningThreshold` - * @property {Boolean} isSoldOut denormalized, Indicates when all variants `inventoryQuantity` is zero - * @property {Boolean} isBackorder denormalized, `true` if product not in stock, but customers anyway could order it - * @property {String[]} supportedFulfillmentTypes Types of fulfillment ("shipping", "pickup", etc) allowed for this product - * @property {ShippingParcel} parcel optional - * @property {String[]} hashtags optional - * @property {String} twitterMsg optional * @property {String} facebookMsg optional * @property {String} googleplusMsg optional - * @property {String} pinterestMsg optional - * @property {String} metaDescription optional * @property {String} handle optional, slug + * @property {String[]} hashtags optional + * @property {Boolean} isBackorder denormalized, `true` if product not in stock, but customers anyway could order it * @property {Boolean} isDeleted, default value: `false` + * @property {Boolean} isLowQuantity denormalized, true when at least 1 variant is below `lowInventoryWarningThreshold` + * @property {Boolean} isSoldOut denormalized, Indicates when all variants `inventoryQuantity` is zero * @property {Boolean} isVisible, default value: `false` + * @property {String} metaDescription optional + * @property {Metafield[]} metafields optional + * @property {String} originCountry optional + * @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 + * @property {String} shopId Product ShopID + * @property {Boolean} shouldAppearInSitemap optional, whether this product should appear in auto-generated sitemap.xml + * @property {String[]} supportedFulfillmentTypes Types of fulfillment ("shipping", "pickup", etc) allowed for this product * @property {String} template, default value: `"productDetailSimple"` - * @property {Date} createdAt required + * @property {String} title Product Title + * @property {String} twitterMsg optional + * @property {String} type default value: `"simple"` * @property {Date} updatedAt optional - * @property {Date} publishedAt optional - * @property {String} publishedScope optional + * @property {String} vendor optional * @property {Workflow} workflow optional - * @property {String} publishedProductHash optional */ export const Product = new SimpleSchema({ "_id": { type: String, - label: "Product Id" + label: "Product ID" }, "ancestors": { type: Array, @@ -428,17 +391,12 @@ export const Product = new SimpleSchema({ "ancestors.$": { type: String }, - "shopId": { - type: String, - index: 1, - label: "Product ShopId" - }, - "title": { - type: String, - defaultValue: "", - label: "Product Title" + "createdAt": { + type: Date, + autoValue: createdAtAutoValue, + index: 1 }, - "pageTitle": { + "currentProductHash": { type: String, optional: true }, @@ -446,33 +404,39 @@ export const Product = new SimpleSchema({ type: String, optional: true }, - "productType": { - type: String, - optional: true - }, - "originCountry": { + "facebookMsg": { type: String, - optional: true + optional: true, + max: 255 }, - "type": { - label: "Type", + "googleplusMsg": { type: String, - defaultValue: "simple" + optional: true, + max: 255 }, - "vendor": { + "handle": { type: String, - optional: true + optional: true, + index: 1 }, - "metafields": { + "hashtags": { type: Array, - optional: true + optional: true, + index: 1 }, - "metafields.$": { - type: Metafield + "hashtags.$": { + type: String }, - "price": { - label: "Price", - type: PriceRange + "isBackorder": { + label: "Indicates when the seller has allowed the sale of product which" + + " is not in stock", + type: Boolean, + optional: true + }, + "isDeleted": { + type: Boolean, + index: 1, + defaultValue: false }, "isLowQuantity": { label: "Indicates that the product quantity is too low", @@ -484,93 +448,97 @@ export const Product = new SimpleSchema({ type: Boolean, optional: true }, - "isBackorder": { - label: "Indicates when the seller has allowed the sale of product which" + - " is not in stock", + "isVisible": { type: Boolean, - optional: true - }, - "supportedFulfillmentTypes": { - type: Array, - label: "Supported fulfillment types", - defaultValue: ["shipping"] + index: 1, + defaultValue: false }, - "supportedFulfillmentTypes.$": String, - "parcel": { - type: ShippingParcel, + "metaDescription": { + type: String, optional: true }, - "hashtags": { + "metafields": { type: Array, - optional: true, - index: 1 + optional: true }, - "hashtags.$": { - type: String + "metafields.$": { + type: Metafield }, - "twitterMsg": { + "originCountry": { type: String, - optional: true, - max: 140 + optional: true }, - "facebookMsg": { + "pageTitle": { type: String, - optional: true, - max: 255 + optional: true }, - "googleplusMsg": { - type: String, - optional: true, - max: 255 + "parcel": { + type: ShippingParcel, + optional: true }, "pinterestMsg": { type: String, optional: true, max: 255 }, - "metaDescription": { + "price": { + label: "Price", + type: PriceRange + }, + "productType": { type: String, optional: true }, - "handle": { - type: String, - optional: true, - index: 1 + "publishedAt": { + type: Date, + optional: true }, - "changedHandleWas": { + "publishedProductHash": { type: String, optional: true }, - "isDeleted": { - type: Boolean, + "shopId": { + type: String, index: 1, - defaultValue: false + label: "Product ShopId" }, - "isVisible": { + "shouldAppearInSitemap": { type: Boolean, - index: 1, - defaultValue: false + optional: true, + defaultValue: true + }, + "supportedFulfillmentTypes": { + type: Array, + label: "Supported fulfillment types", + defaultValue: ["shipping"] }, + "supportedFulfillmentTypes.$": String, "template": { label: "Template", type: String, defaultValue: "productDetailSimple" }, - "createdAt": { - type: Date, - autoValue: createdAtAutoValue, - index: 1 + "title": { + type: String, + defaultValue: "", + label: "Product Title" + }, + "twitterMsg": { + type: String, + optional: true, + max: 140 + }, + "type": { + label: "Type", + type: String, + defaultValue: "simple" }, "updatedAt": { type: Date, autoValue: updatedAtAutoValue, optional: true }, - "publishedAt": { - type: Date, - optional: true - }, - "publishedScope": { + "vendor": { type: String, optional: true }, @@ -578,14 +546,6 @@ export const Product = new SimpleSchema({ type: Workflow, optional: true, defaultValue: {} - }, - "currentProductHash": { - type: String, - optional: true - }, - "publishedProductHash": { - type: String, - optional: true } }); diff --git a/imports/collections/schemas/shops.js b/imports/collections/schemas/shops.js index d9f0f72c97b..313e9302926 100644 --- a/imports/collections/schemas/shops.js +++ b/imports/collections/schemas/shops.js @@ -265,6 +265,7 @@ registerSchema("MerchantShop", MerchantShop); * @property {Date} createdAt optional * @property {Date} updatedAt optional * @property {Object[]} paymentMethods blackbox, default value: `[]` + * @property {String[]} availablePaymentMethods default value: `[]` * @property {Workflow} workflow optional */ export const Shop = new SimpleSchema({ @@ -496,6 +497,13 @@ export const Shop = new SimpleSchema({ type: Object, blackbox: true }, + "availablePaymentMethods": { + type: Array, + defaultValue: [] + }, + "availablePaymentMethods.$": { + type: String + }, "workflow": { type: Workflow, optional: true, diff --git a/imports/plugins/core/cart/server/no-meteor/resolvers/CartItem/index.js b/imports/plugins/core/cart/server/no-meteor/resolvers/CartItem/index.js index a71f76da46a..3b64fd7fe63 100644 --- a/imports/plugins/core/cart/server/no-meteor/resolvers/CartItem/index.js +++ b/imports/plugins/core/cart/server/no-meteor/resolvers/CartItem/index.js @@ -1,7 +1,9 @@ import { encodeCartItemOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/cart"; import { resolveShopFromShopId } from "@reactioncommerce/reaction-graphql-utils"; +import productTags from "./productTags"; export default { _id: (node) => encodeCartItemOpaqueId(node._id), + productTags, shop: resolveShopFromShopId }; diff --git a/imports/plugins/core/cart/server/no-meteor/resolvers/CartItem/productTags.js b/imports/plugins/core/cart/server/no-meteor/resolvers/CartItem/productTags.js new file mode 100644 index 00000000000..a4a0d2379a9 --- /dev/null +++ b/imports/plugins/core/cart/server/no-meteor/resolvers/CartItem/productTags.js @@ -0,0 +1,21 @@ +import { getPaginatedResponse } from "@reactioncommerce/reaction-graphql-utils"; +import { xformArrayToConnection } from "@reactioncommerce/reaction-graphql-xforms/connection"; + +/** + * @name "CartItem.productTags" + * @method + * @memberof Catalog/GraphQL + * @summary Returns the tags for a CartItem + * @param {Object} product - CartItem from parent resolver + * @param {TagConnectionArgs} args - arguments sent by the client {@link ConnectionArgs|See default connection arguments} + * @param {Object} context - an object containing the per-request state + * @return {Promise} Promise that resolves with array of Tag objects + */ +export default async function tags(cartItem, connectionArgs, context) { + const { productTagIds } = cartItem; + if (!productTagIds || productTagIds.length === 0) return xformArrayToConnection(connectionArgs, []); + + const query = await context.queries.tagsByIds(context, productTagIds, connectionArgs); + + return getPaginatedResponse(query, connectionArgs); +} diff --git a/imports/plugins/core/cart/server/no-meteor/schemas/cart.graphql b/imports/plugins/core/cart/server/no-meteor/schemas/cart.graphql index 92fc7e178f6..d010985f5cc 100644 --- a/imports/plugins/core/cart/server/no-meteor/schemas/cart.graphql +++ b/imports/plugins/core/cart/server/no-meteor/schemas/cart.graphql @@ -139,6 +139,9 @@ type CartItem implements Node { "The type of product, used to display cart items differently" productType: String + "The list of tags that have been applied to this product" + productTags(after: ConnectionCursor, before: ConnectionCursor, first: ConnectionLimitInt, last: ConnectionLimitInt, sortOrder: SortOrder = asc, sortBy: TagSortByField = _id): TagConnection + "The product vendor" productVendor: String 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 caf7b191e35..b38024962d8 100644 --- a/imports/plugins/core/cart/server/no-meteor/util/addCartItems.js +++ b/imports/plugins/core/cart/server/no-meteor/util/addCartItems.js @@ -119,6 +119,7 @@ export default async function addCartItems(collections, currentItems, inputItems productSlug: catalogProduct.slug, productVendor: catalogProduct.vendor, productType: catalogProduct.type, + productTagIds: catalogProduct.tagIds, quantity, shopId: catalogProduct.shopId, taxCode: chosenVariant.taxCode, diff --git a/imports/plugins/core/catalog/register.js b/imports/plugins/core/catalog/register.js index 32796d85b71..80fd02bbaa3 100644 --- a/imports/plugins/core/catalog/register.js +++ b/imports/plugins/core/catalog/register.js @@ -3,12 +3,16 @@ import mutations from "./server/no-meteor/mutations"; import queries from "./server/no-meteor/queries"; import resolvers from "./server/no-meteor/resolvers"; import schemas from "./server/no-meteor/schemas"; +import startup from "./server/no-meteor/startup"; Reaction.registerPackage({ label: "Catalog", name: "reaction-catalog", icon: "fa fa-book", autoEnable: true, + functionsByType: { + startup: [startup] + }, graphQL: { resolvers, schemas 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 1191df640e2..80241a600d6 100644 --- a/imports/plugins/core/catalog/server/methods/catalog.app-test.js +++ b/imports/plugins/core/catalog/server/methods/catalog.app-test.js @@ -586,8 +586,7 @@ describe("core product methods", function () { title: "" } }, { - selector: { type: "simple" }, - validate: false + bypassCollection2: true }); expect(() => Meteor.call("products/publishProduct", product._id)) diff --git a/imports/plugins/core/catalog/server/methods/catalog.js b/imports/plugins/core/catalog/server/methods/catalog.js index 3208fe64265..a550e44afea 100644 --- a/imports/plugins/core/catalog/server/methods/catalog.js +++ b/imports/plugins/core/catalog/server/methods/catalog.js @@ -367,7 +367,17 @@ function updateCatalogProduct(userId, selector, modifier, validation) { const result = Products.update(selector, modifier, validation); - hashProduct(product._id, rawCollections, false); + hashProduct(product._id, rawCollections, false) + .catch((error) => { + 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' } }); + } Hooks.Events.run("afterUpdateCatalogProduct", product._id, { modifier }); @@ -798,7 +808,6 @@ Meteor.methods({ }); delete newVariant.updatedAt; delete newVariant.createdAt; - delete newVariant.publishedAt; // TODO can variant have this param? result = Products.insert(newVariant, { validate: false }); Hooks.Events.run("afterInsertCatalogProduct", newVariant); 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 0b3ac3cec19..dd86f83e41b 100644 --- a/imports/plugins/core/catalog/server/no-meteor/mutations/hashProduct.js +++ b/imports/plugins/core/catalog/server/no-meteor/mutations/hashProduct.js @@ -1,50 +1,101 @@ import hash from "object-hash"; -import createCatalogProduct from "../utils/createCatalogProduct"; +import { customPublishedProductFields, customPublishedProductVariantFields } from "/imports/plugins/core/core/server/no-meteor/pluginRegistration"; +import getCatalogProductMedia from "../utils/getCatalogProductMedia"; import getTopLevelProduct from "../utils/getTopLevelProduct"; +const productFieldsThatNeedPublishing = [ + "_id", + "description", + "facebookMsg", + "googleplusMsg", + "handle", + "hashtags", + "isDeleted", + "isVisible", + "media", + "metaDescription", + "metafields", + "originCountry", + "pageTitle", + "parcel", + "pinterestMsg", + "price", + "productType", + "shopId", + "supportedFulfillmentTypes", + "template", + "title", + "twitterMsg", + "type", + "vendor" +]; + +const variantFieldsThatNeedPublishing = [ + "_id", + "barcode", + "compareAtPrice", + "height", + "index", + "inventoryManagement", + "inventoryPolicy", + "inventoryQuantity", + "isDeleted", + "isLowQuantity", + "isSoldOut", + "isVisible", + "length", + "lowInventoryWarningThreshold", + "metafields", + "minOrderQuantity", + "optionTitle", + "originCountry", + "price", + "shopId", + "sku", + "taxable", + "taxCode", + "taxDescription", + "title", + "type", + "weight", + "width" +]; + /** * @method createProductHash * @summary Create a hash of a product to compare for updates * @memberof Catalog - * @param {String} productToConvert - A product object + * @param {String} product - The Product document to hash. Expected to be a top-level product, not a variant * @param {Object} collections - Raw mongo collections * @return {String} product hash */ -export async function createProductHash(productToConvert, collections) { - const product = await createCatalogProduct(productToConvert, collections); +export async function createProductHash(product, collections) { + const variants = await collections.Products.find({ ancestors: product._id, type: "variant" }).toArray(); - const hashableFields = { - _id: product._id, - ancestors: product.ancestors, - description: product.description, - facebookMsg: product.facebookMsg, - googleplusMsg: product.googleplusMsg, - handle: product.handle, - hashtags: product.hashtags, - isDeleted: product.isDeleted, - isVisible: product.isVisible, - media: product.media, - metaDescription: product.metaDescription, - metafields: product.metafields, - originCountry: product.originCountry, - pageTitle: product.pageTitle, - parcel: product.parcel, - pinterestMsg: product.pinterestMsg, - productType: product.productType, - price: product.price, - pricing: product.pricing, - publishedScope: product.publishedScope, - shopId: product.shopId, - supportedFulfillmentTypes: product.supportedFulfillmentTypes, - template: product.template, - title: product.title, - twitterMsg: product.twitterMsg, - type: product.type, - variants: product.variants, - vendor: product.vendor - }; + const productForHashing = {}; + productFieldsThatNeedPublishing.forEach((field) => { + productForHashing[field] = product[field]; + }); + customPublishedProductFields.forEach((field) => { + productForHashing[field] = product[field]; + }); + + // Track changes to all related media, too + productForHashing.media = await getCatalogProductMedia(product._id, collections); - return hash(hashableFields); + // Track changes to all variants, too + productForHashing.variants = variants.map((variant) => { + const variantForHashing = {}; + variantFieldsThatNeedPublishing.forEach((field) => { + variantForHashing[field] = variant[field]; + }); + customPublishedProductVariantFields.forEach((field) => { + variantForHashing[field] = variant[field]; + }); + return variantForHashing; + }); + + return hash(productForHashing); } /** @@ -59,9 +110,12 @@ export async function createProductHash(productToConvert, collections) { export default async function hashProduct(productId, collections, isPublished = true) { const { Products } = collections; - const product = await getTopLevelProduct(productId, collections); + const topLevelProduct = await getTopLevelProduct(productId, collections); + if (!topLevelProduct) { + throw new Error(`No top level product found for product with ID ${productId}`); + } - const productHash = await createProductHash(product, collections); + const productHash = await createProductHash(topLevelProduct, collections); // Insert/update product document with hash field const hashFields = { @@ -72,23 +126,14 @@ export default async function hashProduct(productId, collections, isPublished = hashFields.publishedProductHash = productHash; } - const result = await Products.updateOne( - { - _id: product._id - }, - { - $set: { - ...hashFields, - updatedAt: new Date() - } - } - ); + const productUpdates = { + ...hashFields, + updatedAt: new Date() + }; + const result = await Products.updateOne({ _id: topLevelProduct._id }, { $set: productUpdates }); if (result && result.result && result.result.ok === 1) { - // If product was updated, get updated product from database - const updatedProduct = await Products.findOne({ _id: product._id }); - - return updatedProduct; + return { ...topLevelProduct, ...productUpdates }; } return null; 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 c76a462a9ee..aafc11fe0dc 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 @@ -3,17 +3,14 @@ import { rewire as rewire$getCatalogProductMedia, restore as restore$getCatalogProductMedia } from "../utils/getCatalogProductMedia"; -import { rewire as rewire$isBackorder, restore as restore$isBackorder } from "../utils/isBackorder"; -import { rewire as rewire$isLowQuantity, restore as restore$isLowQuantity } from "../utils/isLowQuantity"; -import { rewire as rewire$isSoldOut, restore as restore$isSoldOut } from "../utils/isSoldOut"; -import hashProduct from "./hashProduct"; +import { rewire as rewire$getTopLevelProduct, restore as restore$getTopLevelProduct } from "../utils/getTopLevelProduct"; +import hashProduct, { rewire$createProductHash, restore$createProductHash } from "./hashProduct"; const mockCollections = { ...mockContext.collections }; 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"]; @@ -23,87 +20,6 @@ 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, - isLowQuantity: true, - isSoldOut: false, - isDeleted: 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", - taxable: true, - 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, - isLowQuantity: true, - isSoldOut: false, - isDeleted: 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: 992.0, - shopId: internalShopId, - sku: "sku", - taxable: true, - taxCode: "0000", - taxDescription: "taxDescription", - title: "One pound bag", - variantId: internalVariantIds[1], - weight: 2, - width: 2 - } -]; - const mockProduct = { _id: internalCatalogItemId, shopId: internalShopId, @@ -177,7 +93,6 @@ const mockProduct = { twitterMsg: "twitterMessage", type: "product-simple", updatedAt, - mockVariants, vendor: "vendor", weight: 15.6, width: 8.4, @@ -186,91 +101,6 @@ const mockProduct = { } }; -const updatedMockProduct = { - publishedProductHash: "769f6d8004a2a2929d143ab242625b6c71f618d8", - _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, - taxCode: "taxCode", - taxDescription: "taxDescription", - taxable: false, - title: "Fake Product Title", - twitterMsg: "twitterMessage", - type: "product-simple", - updatedAt, - mockVariants, - vendor: "vendor", - weight: 15.6, - width: 8.4, - workflow: { - status: "new" - } -}; - -const expectedHash = "769f6d8004a2a2929d143ab242625b6c71f618d8"; - const mockGetCatalogProductMedia = jest .fn() .mockName("getCatalogProductMedia") @@ -290,43 +120,65 @@ const mockGetCatalogProductMedia = jest } ])); -const mockIsBackorder = jest - .fn() - .mockName("isBackorder") - .mockReturnValue(false); -const mockIsLowQuantity = jest - .fn() - .mockName("isLowQuantity") - .mockReturnValue(false); -const mockIsSoldOut = jest - .fn() - .mockName("isSoldOut") - .mockReturnValue(false); +const mockCreateProductHash = jest.fn().mockName("createProductHash").mockReturnValue("fake_hash"); +const mockGetTopLevelProduct = jest.fn().mockName("getTopLevelProduct").mockReturnValue(mockProduct); beforeAll(() => { + rewire$createProductHash(mockCreateProductHash); rewire$getCatalogProductMedia(mockGetCatalogProductMedia); - rewire$isBackorder(mockIsBackorder); - rewire$isLowQuantity(mockIsLowQuantity); - rewire$isSoldOut(mockIsSoldOut); + rewire$getTopLevelProduct(mockGetTopLevelProduct); }); afterAll(() => { - restore$isBackorder(); - restore$isLowQuantity(); - restore$isSoldOut(); + restore$createProductHash(); restore$getCatalogProductMedia(); + restore$getTopLevelProduct(); }); -test("publishedProductHash", async () => { +test("successful update when publishing", async () => { mockCollections.Products.updateOne.mockReturnValueOnce(Promise.resolve({ result: { ok: 1 } })); - mockCollections.Products.findOne.mockReturnValue(Promise.resolve(updatedMockProduct)); - const spec = await hashProduct(mockProduct._id, mockCollections); + const updatedProduct = await hashProduct(mockProduct._id, mockCollections); - expect(spec.publishedProductHash).toEqual(expectedHash); + expect(mockCollections.Products.updateOne).toHaveBeenCalledWith({ + _id: mockProduct._id + }, { + $set: { + currentProductHash: "fake_hash", + publishedProductHash: "fake_hash", + updatedAt: jasmine.any(Date) + } + }); + expect(updatedProduct.currentProductHash).toEqual("fake_hash"); + expect(updatedProduct.publishedProductHash).toEqual("fake_hash"); }); -test("publishedProductHash was not successfully created, return original product", async () => { +test("when update fails, returns null", async () => { mockCollections.Products.updateOne.mockReturnValueOnce(Promise.resolve({ result: { ok: 0 } })); - const spec = await hashProduct(mockProduct._id, mockCollections); - expect(spec).toEqual(null); + const updatedProduct = await hashProduct(mockProduct._id, mockCollections); + + expect(mockCollections.Products.updateOne).toHaveBeenCalledWith({ + _id: mockProduct._id + }, { + $set: { + currentProductHash: "fake_hash", + publishedProductHash: "fake_hash", + updatedAt: jasmine.any(Date) + } + }); + expect(updatedProduct).toEqual(null); +}); + +test("does not update publishedProductHash when isPublished arg is false", async () => { + mockCollections.Products.updateOne.mockReturnValueOnce(Promise.resolve({ result: { ok: 1 } })); + const updatedProduct = await hashProduct(mockProduct._id, mockCollections, false); + + expect(mockCollections.Products.updateOne).toHaveBeenCalledWith({ + _id: mockProduct._id + }, { + $set: { + currentProductHash: "fake_hash", + updatedAt: jasmine.any(Date) + } + }); + expect(updatedProduct.currentProductHash).toEqual("fake_hash"); }); diff --git a/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.js b/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.js index 9fcc1c69974..d138decd96c 100644 --- a/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.js +++ b/imports/plugins/core/catalog/server/no-meteor/mutations/publishProducts.js @@ -39,7 +39,7 @@ export default async function publishProducts(context, productIds) { }); } - const success = await publishProductsToCatalog(productIds, collections); + const success = await publishProductsToCatalog(productIds, context); if (!success) { Logger.error("Some Products could not be published to the Catalog."); throw new ReactionError("server-error", "Some Products could not be published to the Catalog. Make sure your variants are visible."); diff --git a/imports/plugins/core/catalog/server/no-meteor/resolvers/ProductPricingInfo.js b/imports/plugins/core/catalog/server/no-meteor/resolvers/ProductPricingInfo.js index a109fe322d2..150c5b29ec3 100644 --- a/imports/plugins/core/catalog/server/no-meteor/resolvers/ProductPricingInfo.js +++ b/imports/plugins/core/catalog/server/no-meteor/resolvers/ProductPricingInfo.js @@ -1,5 +1,7 @@ -import { getXformedCurrencyByCode } from "@reactioncommerce/reaction-graphql-xforms/currency"; +import { getXformedCurrencyByCode, xformCurrencyExchangePricing } from "@reactioncommerce/reaction-graphql-xforms/currency"; export default { - currency: (node) => getXformedCurrencyByCode(node.currencyCode) + currency: (node) => getXformedCurrencyByCode(node.currencyCode), + currencyExchangePricing: (node, { currencyCode }, context) => xformCurrencyExchangePricing(node, currencyCode, context), + compareAtPrice: ({ compareAtPrice: amount, currencyCode }) => (typeof amount === "number" && amount > 0 ? { amount, currencyCode } : null) }; 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 5f6772fce44..a6216ef8ad1 100644 --- a/imports/plugins/core/catalog/server/no-meteor/schemas/schema.graphql +++ b/imports/plugins/core/catalog/server/no-meteor/schemas/schema.graphql @@ -26,7 +26,44 @@ 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: Float + 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! diff --git a/imports/plugins/core/catalog/server/no-meteor/startup.js b/imports/plugins/core/catalog/server/no-meteor/startup.js new file mode 100644 index 00000000000..ec6c5ba8725 --- /dev/null +++ b/imports/plugins/core/catalog/server/no-meteor/startup.js @@ -0,0 +1,52 @@ +import Logger from "@reactioncommerce/logger"; +import hashProduct from "./mutations/hashProduct"; + +/** + * @summary Recalculate the currentProductHash for the related product + * @param {Object} media The media document + * @param {Object} collections Map of MongoDB collections + * @return {Promise} Null + */ +async function hashRelatedProduct(media, collections) { + if (!media) { + throw new Error("hashRelatedProduct called with no media argument"); + } + + const { productId } = media.metadata || {}; + if (productId) { + hashProduct(productId, collections, false) + .catch((error) => { + Logger.error(`Error updating currentProductHash for product with ID ${productId}`, error); + }); + } + + return null; +} + +/** + * @summary Called on startup + * @param {Object} context Startup context + * @param {Object} context.collections Map of MongoDB collections + * @returns {undefined} + */ +export default function startup(context) { + const { appEvents, collections } = context; + + appEvents.on("afterMediaInsert", (media) => { + hashRelatedProduct(media, collections).catch((error) => { + Logger.error("Error in afterMediaInsert", error); + }); + }); + + appEvents.on("afterMediaUpdate", (media) => { + hashRelatedProduct(media, collections).catch((error) => { + Logger.error("Error in afterMediaUpdate", error); + }); + }); + + appEvents.on("afterMediaRemove", (media) => { + hashRelatedProduct(media, collections).catch((error) => { + Logger.error("Error in afterMediaRemove", error); + }); + }); +} 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 02816e81e96..685e594279e 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js @@ -61,53 +61,21 @@ export function xformVariant(variant, variantPriceInfo, shopCurrencyCode, varian } /** - * @method createCatalogProduct - * @summary Publish a product to the Catalog collection - * @memberof Catalog - * @param {Object} product - A product object - * @param {Object} collections - Raw mongo collections - * @return {boolean} true on successful publish, false if publish was unsuccessful + * @summary The core function for transforming a Product to a CatalogProduct + * @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 default async function createCatalogProduct(product, collections) { - const { Products, Shops } = collections; - - if (!product) { - Logger.info("Cannot publish to catalog: undefined product"); - return false; - } - - if (Array.isArray(product.ancestors) && product.ancestors.length) { - Logger.info("Cannot publish to catalog: product is a variant"); - return false; - } - - const shop = await Shops.findOne( - { _id: product.shopId }, - { - fields: { - currencies: 1, - currency: 1 - } - } - ); - if (!shop) { - Logger.info(`Cannot publish to catalog: product's shop (ID ${product.shopId}) not found`); - return false; - } - +export async function xformProduct({ collections, product, shop, variants }) { const shopCurrencyCode = shop.currency; const shopCurrencyInfo = shop.currencies[shopCurrencyCode]; const catalogProductMedia = await getCatalogProductMedia(product._id, collections); const primaryImage = catalogProductMedia.find(({ toGrid }) => toGrid === 1) || null; - // Get all variants of the product and denormalize them into an array on the CatalogProduct - const variants = await Products.find({ - ancestors: product._id, - isDeleted: { $ne: true }, - isVisible: { $ne: false } - }).toArray(); - const topVariants = []; const options = new Map(); @@ -152,7 +120,8 @@ export default async function createCatalogProduct(product, collections) { }); const productPriceInfo = getPriceRange(prices, shopCurrencyInfo); - const catalogProduct = { + + 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, barcode: product.barcode, @@ -208,6 +177,58 @@ export default async function createCatalogProduct(product, collections) { weight: product.weight, width: product.width }; +} + +/** + * @method createCatalogProduct + * @summary Publish a product to the Catalog collection + * @memberof Catalog + * @param {Object} product - A product object + * @param {Object} context - The app context + * @return {boolean} true on successful publish, false if publish was unsuccessful + */ +export default async function createCatalogProduct(product, context) { + const { collections, getFunctionsOfType } = context; + const { Products, Shops } = collections; + + if (!product) { + Logger.error("Cannot publish to catalog: undefined product"); + return false; + } + + if (Array.isArray(product.ancestors) && product.ancestors.length) { + Logger.error("Cannot publish to catalog: product is a variant"); + return false; + } + + const shop = await Shops.findOne( + { _id: product.shopId }, + { + fields: { + currencies: 1, + currency: 1 + } + } + ); + if (!shop) { + Logger.error(`Cannot publish to catalog: product's shop (ID ${product.shopId}) not found`); + return false; + } + + // Get all variants of the product and denormalize them into an array on the CatalogProduct + const variants = await Products.find({ + ancestors: product._id, + isDeleted: { $ne: true }, + isVisible: { $ne: false } + }).toArray(); + + const catalogProduct = await xformProduct({ collections, product, shop, variants }); + + // Apply custom transformations from plugins. + getFunctionsOfType("publishProductToCatalog").forEach((customPublishFunc) => { + // Functions of type "publishProductToCatalog" are expected to mutate the provided catalogProduct. + customPublishFunc(catalogProduct, { context, product, shop, variants }); + }); return catalogProduct; } 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 1360ab97fb7..82821e6f2dd 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 @@ -6,9 +6,7 @@ import { import { rewire as rewire$isBackorder, restore as restore$isBackorder } from "./isBackorder"; import { rewire as rewire$isLowQuantity, restore as restore$isLowQuantity } from "./isLowQuantity"; import { rewire as rewire$isSoldOut, restore as restore$isSoldOut } from "./isSoldOut"; -import createCatalogProduct from "./createCatalogProduct"; - -const mockCollections = { ...mockContext.collections }; +import createCatalogProduct, { restore$createCatalogProduct, rewire$xformProduct } from "./createCatalogProduct"; const internalShopId = "123"; const opaqueShopId = "cmVhY3Rpb24vc2hvcDoxMjM="; // reaction/shop:123 @@ -29,6 +27,7 @@ const mockVariants = [ ancestors: [internalCatalogProductId], barcode: "barcode", createdAt, + compareAtPrice: 1100, height: 0, index: 0, inventoryManagement: true, @@ -391,7 +390,7 @@ const mockCatalogProduct = { price: 0, pricing: { USD: { - compareAtPrice: null, + compareAtPrice: 1100, displayPrice: "$992.00", maxPrice: 992, minPrice: 992, @@ -459,13 +458,37 @@ afterAll(() => { restore$isLowQuantity(); restore$isSoldOut(); restore$getCatalogProductMedia(); + restore$createCatalogProduct(); }); test("convert product object to catalog object", async () => { - mockCollections.Products.toArray.mockReturnValueOnce(Promise.resolve(mockVariants)); - mockCollections.Shops.findOne.mockReturnValueOnce(Promise.resolve(mockShop)); - mockCollections.Catalog.updateOne.mockReturnValueOnce(Promise.resolve({ result: { ok: 0 } })); - const spec = await createCatalogProduct(mockProduct, mockCollections); + mockContext.collections.Products.toArray.mockReturnValueOnce(Promise.resolve(mockVariants)); + mockContext.collections.Shops.findOne.mockReturnValueOnce(Promise.resolve(mockShop)); + const spec = await createCatalogProduct(mockProduct, mockContext); expect(spec).toEqual(mockCatalogProduct); }); + +test("calls functions of type publishProductToCatalog, which can mutate the catalog product", async () => { + mockContext.collections.Products.toArray.mockReturnValueOnce(Promise.resolve(mockVariants)); + mockContext.collections.Shops.findOne.mockReturnValueOnce(Promise.resolve(mockShop)); + + rewire$xformProduct(() => ({ mock: true })); + + const mockCustomPublisher = jest.fn().mockName("mockCustomPublisher").mockImplementation((obj) => { + obj.foo = "bar"; + }); + + const catalogProduct = await createCatalogProduct({}, { + ...mockContext, + getFunctionsOfType: () => [mockCustomPublisher] + }); + + expect(catalogProduct).toEqual({ foo: "bar", mock: true }); + expect(mockCustomPublisher).toHaveBeenCalledWith({ foo: "bar", mock: true }, { + context: jasmine.any(Object), + product: {}, + shop: mockShop, + variants: mockVariants + }); +}); diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/getDisplayPrice.js b/imports/plugins/core/catalog/server/no-meteor/utils/getDisplayPrice.js new file mode 100644 index 00000000000..41ead71dfc8 --- /dev/null +++ b/imports/plugins/core/catalog/server/no-meteor/utils/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/core/catalog/server/no-meteor/utils/getDisplayPrice.test.js new file mode 100644 index 00000000000..c270e83bfdb --- /dev/null +++ b/imports/plugins/core/catalog/server/no-meteor/utils/getDisplayPrice.test.js @@ -0,0 +1,33 @@ +import getDisplayPrice from "./getDisplayPrice"; + +const mockUSDCurrencyInfo = { + enabled: true, + format: "%s%v", + symbol: "$", + rate: 1 +}; +const minUSDPrice = 10; +const maxUSDPrice = 19.99; +const expectedUSDDisplayPrice = "$10.00 - $19.99"; + +const mockRUBCurrencyInfo = { + enabled: true, + format: "%v %s", + symbol: "руб.", + decimal: ",", + thousand: " ", + scale: 0, + where: "right", + rate: 68.017871 +}; +const minRUBPrice = 680.18; +const maxRUBPrice = 1359.68; +const expectedRUBDisplayPrice = "680,18 - 1 359,68 руб."; + +test("getDisplayPrice correctly formats USD price range", () => { + expect(getDisplayPrice(minUSDPrice, maxUSDPrice, mockUSDCurrencyInfo)).toBe(expectedUSDDisplayPrice); +}); + +test("getDisplayPrice correctly formats RUB price range", () => { + expect(getDisplayPrice(minRUBPrice, maxRUBPrice, mockRUBCurrencyInfo)).toBe(expectedRUBDisplayPrice); +}); diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/getPriceRange.js b/imports/plugins/core/catalog/server/no-meteor/utils/getPriceRange.js index 39fa981e5a1..add3e9c866c 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/getPriceRange.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/getPriceRange.js @@ -1,4 +1,5 @@ import accounting from "accounting-js"; +import getDisplayPrice from "./getDisplayPrice"; /** * A wrapper around accounting.formatMoney that handles minor differences between Reaction @@ -41,10 +42,11 @@ export function formatMoney(price, currencyInfo) { */ export default function getPriceRange(prices, currencyInfo) { if (prices.length === 1) { + const price = prices[0]; return { - range: formatMoney(prices[0], currencyInfo), - min: prices[0], - max: prices[0] + range: getDisplayPrice(price, price, currencyInfo), + min: price, + max: price }; } @@ -60,15 +62,8 @@ export default function getPriceRange(prices, currencyInfo) { } }); - if (priceMin === priceMax) { - return { - range: formatMoney(priceMin, currencyInfo), - min: priceMin, - max: priceMax - }; - } return { - range: `${formatMoney(priceMin, currencyInfo)} - ${formatMoney(priceMax, currencyInfo)}`, + 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/core/catalog/server/no-meteor/utils/getPriceRange.test.js index 27cfd7d4cf5..8f6ece11ba7 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/getPriceRange.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/getPriceRange.test.js @@ -18,7 +18,7 @@ test("expect price object with zero values when provided price values of 0", () }); // epxect price object to have same value when only one price -test("epxect price object to have same value when only one price", () => { +test("expect price object to have same value when only one price", () => { const spec = getPriceRange(mockSinglePrice); const success = { range: "5.99", @@ -29,7 +29,7 @@ test("epxect price object to have same value when only one price", () => { }); // epxect price object to have same value when provided prices are the same -test("epxect price object to have same value when provided prices are the same", () => { +test("expect price object to have same value when provided prices are the same", () => { const spec = getPriceRange(mockSamePrices); const success = { range: "2.99", diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.js b/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.js index e726e52b658..43c3e249ff2 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/getTopLevelProduct.js @@ -2,7 +2,7 @@ * * @method getTopLevelProduct * @summary Get a top level product based on provided ID - * @param {string} productOrVariantId - A variant or top level Product Variant ID. + * @param {String} productOrVariantId - A variant or top level Product Variant ID. * @param {Object} collections - Raw mongo collections. * @return {Promise} Top level product object. */ diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.js b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.js index 88d24987a51..c85854a02c4 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalog.js @@ -1,7 +1,8 @@ +import Hooks from "@reactioncommerce/hooks"; +import Logger from "@reactioncommerce/logger"; import Random from "@reactioncommerce/random"; import * as Schemas from "/imports/collections/schemas"; -import Logger from "@reactioncommerce/logger"; -import hashProduct from "../mutations/hashProduct"; +import { createProductHash } from "../mutations/hashProduct"; import createCatalogProduct from "./createCatalogProduct"; /** @@ -9,17 +10,14 @@ import createCatalogProduct from "./createCatalogProduct"; * @summary Publish a product to the Catalog collection * @memberof Catalog * @param {Object} product - A product object - * @param {Object} collections - Raw mongo collections + * @param {Object} context - The app context * @return {boolean} true on successful publish, false if publish was unsuccessful */ -export default async function publishProductToCatalog(product, collections) { - const { Catalog } = collections; - - // Create hash of all user-editable fields - const hashedProduct = await hashProduct(product._id, collections); +export default async function publishProductToCatalog(product, context) { + const { Catalog, Products } = context.collections; // Convert Product schema object to Catalog schema object - const catalogProduct = await createCatalogProduct(hashedProduct, collections); + const catalogProduct = await createCatalogProduct(product, context); // Check to see if product has variants // If not, do not publish the product to the Catalog @@ -51,5 +49,27 @@ export default async function publishProductToCatalog(product, collections) { { upsert: true } ); - return result && result.result && result.result.ok === 1; + const wasUpdateSuccessful = result && result.result && result.result.ok === 1; + if (wasUpdateSuccessful) { + // Update the Product hashes so that we know there are now no unpublished changes + const productHash = await createProductHash(product, context.collections); + + const now = new Date(); + const productUpdates = { + currentProductHash: productHash, + publishedAt: now, + publishedProductHash: productHash, + updatedAt: now + }; + + const productUpdateResult = await Products.updateOne({ _id: product._id }, { $set: productUpdates }); + if (!productUpdateResult || !productUpdateResult.result || productUpdateResult.result.ok !== 1) { + Logger.error(`Failed to update product hashes for product with ID ${product._id}`, productUpdateResult && productUpdateResult.result); + } + + const updatedProduct = { ...product, ...productUpdates }; + Hooks.Events.run("afterPublishProductToCatalog", updatedProduct, catalogProduct); + } + + return wasUpdateSuccessful; } 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 b918dd44be0..5c18af0ecf0 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 @@ -9,8 +9,6 @@ import { rewire as rewire$isSoldOut, restore as restore$isSoldOut } from "./isSo import { rewire as rewire$createCatalogProduct, restore as restore$createCatalogProduct } from "./createCatalogProduct"; import publishProductToCatalog from "./publishProductToCatalog"; -const mockCollections = { ...mockContext.collections }; - const internalShopId = "123"; const opaqueShopId = "cmVhY3Rpb24vc2hvcDoxMjM="; // reaction/shop:123 const internalCatalogItemId = "999"; @@ -313,18 +311,18 @@ afterAll(() => { }); test("expect true if a product is published to the catalog collection", async () => { - mockCollections.Products.toArray.mockReturnValueOnce(Promise.resolve(mockVariants)); - mockCollections.Shops.findOne.mockReturnValueOnce(Promise.resolve(mockShop)); - mockCollections.Products.findOne.mockReturnValue(Promise.resolve(updatedMockProduct)); - mockCollections.Catalog.updateOne.mockReturnValueOnce(Promise.resolve({ result: { ok: 1 } })); - const spec = await publishProductToCatalog(mockProduct, mockCollections); + mockContext.collections.Products.toArray.mockReturnValueOnce(Promise.resolve(mockVariants)); + mockContext.collections.Shops.findOne.mockReturnValueOnce(Promise.resolve(mockShop)); + mockContext.collections.Products.findOne.mockReturnValue(Promise.resolve(updatedMockProduct)); + mockContext.collections.Catalog.updateOne.mockReturnValueOnce(Promise.resolve({ result: { ok: 1 } })); + const spec = await publishProductToCatalog(mockProduct, mockContext); expect(spec).toBe(true); }); test("expect false if a product is not published to the catalog collection", async () => { - mockCollections.Products.toArray.mockReturnValueOnce(Promise.resolve(mockVariants)); - mockCollections.Shops.findOne.mockReturnValueOnce(Promise.resolve(mockShop)); - mockCollections.Catalog.updateOne.mockReturnValueOnce(Promise.resolve({ result: { ok: 0 } })); - const spec = await publishProductToCatalog(mockProduct, mockCollections); + mockContext.collections.Products.toArray.mockReturnValueOnce(Promise.resolve(mockVariants)); + mockContext.collections.Shops.findOne.mockReturnValueOnce(Promise.resolve(mockShop)); + mockContext.collections.Catalog.updateOne.mockReturnValueOnce(Promise.resolve({ result: { ok: 0 } })); + const spec = await publishProductToCatalog(mockProduct, mockContext); expect(spec).toBe(false); }); diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.js b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.js index 746985c4da9..b5657a74150 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductToCatalogById.js @@ -5,11 +5,11 @@ import publishProductToCatalog from "./publishProductToCatalog"; * @summary Publish a product to the Catalog by ID * @memberof Catalog * @param {string} productId - A product ID. Must be a top-level product. - * @param {Object} collections - Raw mongo collections + * @param {Object} context - The app context * @return {boolean} true on successful publish, false if publish was unsuccessful */ -export default async function publishProductToCatalogById(productId, collections) { - const { Products } = collections; +export default async function publishProductToCatalogById(productId, context) { + const { Products } = context.collections; const product = await Products.findOne({ _id: productId }); - return publishProductToCatalog(product, collections); + return publishProductToCatalog(product, context); } 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 7324c37ae13..d0bfb7be23e 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 @@ -5,7 +5,6 @@ import { } from "./publishProductToCatalog"; import publishProductToCatalogById from "./publishProductToCatalogById"; -const mockCollections = { ...mockContext.collections }; const mockPublishProductToCatalog = jest.fn().mockName("publishProductToCatalog"); const internalShopId = "123"; @@ -188,15 +187,15 @@ beforeAll(() => { afterAll(restore$publishProductToCatalog); test("expect true if a product is published to the catalog collection by product id", async () => { - mockCollections.Products.findOne.mockReturnValueOnce(Promise.resolve(mockProduct)); + mockContext.collections.Products.findOne.mockReturnValueOnce(Promise.resolve(mockProduct)); mockPublishProductToCatalog.mockReturnValueOnce(Promise.resolve(true)); - const spec = await publishProductToCatalogById(internalProductId, mockCollections); + const spec = await publishProductToCatalogById(internalProductId, mockContext); expect(spec).toBe(true); }); test("expect false if a product is not published to the catalog collection by product id", async () => { - mockCollections.Products.findOne.mockReturnValueOnce(Promise.resolve(mockProduct)); + mockContext.collections.Products.findOne.mockReturnValueOnce(Promise.resolve(mockProduct)); mockPublishProductToCatalog.mockReturnValueOnce(Promise.resolve(false)); - const spec = await publishProductToCatalogById(internalProductId, mockCollections); + const spec = await publishProductToCatalogById(internalProductId, mockContext); expect(spec).toBe(false); }); diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.js b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.js index 2d5725c6cb4..55f18360895 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/publishProductsToCatalog.js @@ -5,11 +5,11 @@ import publishProductToCatalogById from "./publishProductToCatalogById"; * @summary Publish one or more products to the Catalog * @memberof Catalog * @param {Array} productIds - An array of product IDs. Must be top-level products. - * @param {Object} collections - Raw mongo collections + * @param {Object} context - The app context * @return {boolean} true on successful publish for all documents, false if one ore more fail to publish */ -export default async function publishProductsToCatalog(productIds, collections) { - const promises = productIds.map((product) => publishProductToCatalogById(product, collections)); +export default async function publishProductsToCatalog(productIds, context) { + const promises = productIds.map((product) => publishProductToCatalogById(product, context)); const results = await Promise.all(promises); return results.every((result) => result); } 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 5f0ab73e78b..4fcee4efe60 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 @@ -5,7 +5,6 @@ import { } from "./publishProductToCatalogById"; import publishProductsToCatalog from "./publishProductsToCatalog"; -const mockCollections = { ...mockContext.collections }; const mockPublishProductToCatalogById = jest.fn().mockName("publishProductToCatalogById"); const internalShopId = "123"; @@ -188,18 +187,18 @@ beforeAll(() => { afterAll(restore$publishProductToCatalogById); test("expect true if an array of products are published to the catalog collection by id", async () => { - mockCollections.Products.findOne.mockReturnValueOnce(Promise.resolve(mockProduct)); + mockContext.collections.Products.findOne.mockReturnValueOnce(Promise.resolve(mockProduct)); mockPublishProductToCatalogById .mockReturnValueOnce(Promise.resolve(true)) .mockReturnValueOnce(Promise.resolve(true)) .mockReturnValueOnce(Promise.resolve(true)); - const spec = await publishProductsToCatalog(["123", "456", "999"], mockCollections); + const spec = await publishProductsToCatalog(["123", "456", "999"], mockContext); expect(spec).toBe(true); }); test("expect false if an array of products are not published to the catalog collection by id", async () => { - mockCollections.Products.findOne.mockReturnValue(Promise.resolve(mockProduct)); + mockContext.collections.Products.findOne.mockReturnValue(Promise.resolve(mockProduct)); mockPublishProductToCatalogById.mockReturnValueOnce(Promise.resolve(false)); - const spec = await publishProductsToCatalog(["123", "456", "999"], mockCollections); + const spec = await publishProductsToCatalog(["123", "456", "999"], mockContext); expect(spec).toBe(false); }); diff --git a/imports/plugins/core/core/server/Reaction/core.js b/imports/plugins/core/core/server/Reaction/core.js index 2b09e09e64b..94a4c71ae1e 100644 --- a/imports/plugins/core/core/server/Reaction/core.js +++ b/imports/plugins/core/core/server/Reaction/core.js @@ -10,7 +10,16 @@ import { Roles } from "meteor/alanning:roles"; import { EJSON } from "meteor/ejson"; import * as Collections from "/lib/collections"; import ConnectionDataStore from "/imports/plugins/core/core/server/util/connectionDataStore"; -import { mutations, queries, resolvers, schemas, functionsByType } from "../no-meteor/pluginRegistration"; +import { + customPublishedProductFields, + customPublishedProductVariantFields, + functionsByType, + mutations, + paymentMethods, + queries, + resolvers, + schemas +} from "../no-meteor/pluginRegistration"; import createGroups from "./createGroups"; import processJobs from "./processJobs"; import sendVerificationEmail from "./sendVerificationEmail"; @@ -82,12 +91,15 @@ export default { schemas.push(...packageInfo.graphQL.schemas); } } + 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])) { @@ -97,6 +109,25 @@ export default { }); } + if (packageInfo.paymentMethods) { + for (const paymentMethod of packageInfo.paymentMethods) { + paymentMethods[paymentMethod.name] = { + ...paymentMethod, + pluginName: packageInfo.name + }; + } + } + + if (packageInfo.catalog) { + const { publishedProductFields, publishedProductVariantFields } = packageInfo.catalog; + if (Array.isArray(publishedProductFields)) { + customPublishedProductFields.push(...publishedProductFields); + } + if (Array.isArray(publishedProductVariantFields)) { + customPublishedProductVariantFields.push(...publishedProductVariantFields); + } + } + // Save the package info this.Packages[packageInfo.name] = packageInfo; const registeredPackage = this.Packages[packageInfo.name]; diff --git a/imports/plugins/core/core/server/fixtures/cart.js b/imports/plugins/core/core/server/fixtures/cart.js index e2976093e6f..8c9433f6b06 100755 --- a/imports/plugins/core/core/server/fixtures/cart.js +++ b/imports/plugins/core/core/server/fixtures/cart.js @@ -23,7 +23,10 @@ import { addProduct } from "./products"; */ export function getCartItem(options = {}) { const product = addProduct(); - Promise.await(publishProductToCatalog(product, rawCollections)); + Promise.await(publishProductToCatalog(product, { + collections: rawCollections, + getFunctionsOfType: () => [] + })); const variant = Products.findOne({ ancestors: [product._id] }); const childVariants = Products.find({ ancestors: [ diff --git a/imports/plugins/core/core/server/no-meteor/pluginRegistration.js b/imports/plugins/core/core/server/no-meteor/pluginRegistration.js index 490dd646e72..7f5c4d7f89b 100644 --- a/imports/plugins/core/core/server/no-meteor/pluginRegistration.js +++ b/imports/plugins/core/core/server/no-meteor/pluginRegistration.js @@ -1,5 +1,8 @@ +export const customPublishedProductFields = []; +export const customPublishedProductVariantFields = []; export const functionsByType = {}; export const mutations = {}; export const queries = {}; export const resolvers = {}; export const schemas = []; +export const paymentMethods = {}; diff --git a/imports/plugins/core/core/server/startup/startNodeApp.js b/imports/plugins/core/core/server/startup/startNodeApp.js index 670836c78d4..69b97b64ea2 100644 --- a/imports/plugins/core/core/server/startup/startNodeApp.js +++ b/imports/plugins/core/core/server/startup/startNodeApp.js @@ -63,7 +63,7 @@ export default async function startNodeApp() { // Log to inform that the server is running WebApp.httpServer.on("listening", () => { - Logger.info(`GraphQL listening at ${ROOT_URL}graphql-alpha`); - Logger.info(`GraphiQL UI: ${ROOT_URL}graphiql`); + Logger.info(`GraphQL listening at ${ROOT_URL}/graphql-alpha`); + Logger.info(`GraphiQL UI: ${ROOT_URL}/graphiql`); }); } diff --git a/imports/plugins/core/dashboard/client/components/actionView.js b/imports/plugins/core/dashboard/client/components/actionView.js index be919a8d35f..da405e65133 100644 --- a/imports/plugins/core/dashboard/client/components/actionView.js +++ b/imports/plugins/core/dashboard/client/components/actionView.js @@ -2,9 +2,9 @@ import React, { Component } from "react"; import { compose } from "recompose"; import PropTypes from "prop-types"; import classnames from "classnames"; +import { isEqual } from "lodash"; import { getComponent, withCSSTransition } from "@reactioncommerce/reaction-components"; import Blaze from "meteor/gadicc:blaze-react-component"; -import { EJSON } from "meteor/ejson"; import { Admin } from "/imports/plugins/core/ui/client/providers"; import Radium from "radium"; import debounce from "lodash/debounce"; @@ -163,10 +163,10 @@ class ActionView extends Component { const stateUpdates = { prevProps: props }; - if (!EJSON.equals(actionView, prevProps.actionView)) { + if (isEqual(actionView, prevProps.actionView) === false) { stateUpdates.actionView = actionView; } - if (!EJSON.equals(detailView, prevProps.detailView)) { + if (isEqual(detailView, prevProps.detailView) === false) { stateUpdates.detailView = detailView; } @@ -195,6 +195,19 @@ class ActionView extends Component { if (window) { window.addEventListener("resize", this.handleResize, false); } + + const { actionView } = this.props; + if (actionView) { + this.setState({ actionView }); + } + } + + componentDidUpdate(prevProps) { + const { actionView } = this.props; + + if (isEqual(actionView, prevProps.actionView) === false) { + this.setState({ actionView }); + } } componentWillUnmount() { diff --git a/imports/plugins/core/files/server/methods.js b/imports/plugins/core/files/server/methods.js index 5b190665e77..fd0b3652d04 100644 --- a/imports/plugins/core/files/server/methods.js +++ b/imports/plugins/core/files/server/methods.js @@ -1,5 +1,6 @@ import { Meteor } from "meteor/meteor"; import { check } from "meteor/check"; +import appEvents from "/imports/node-app/core/util/appEvents"; import Reaction from "/imports/plugins/core/core/server/Reaction"; import ReactionError from "@reactioncommerce/reaction-error"; import { MediaRecords } from "/lib/collections"; @@ -9,30 +10,6 @@ import { MediaRecords } from "/lib/collections"; * @namespace Media/Methods */ -/** - * @method updateMediaMetadata - * @memberof Media/Methods - * @summary Updates a media record. - * @param {String} fileRecordId - _id of updated file record. - * @param {Object} metadata - metadata from updated media file. - * @return {Boolean} - * @private - */ -async function updateMediaMetadata(fileRecordId, metadata) { - check(fileRecordId, String); - check(metadata, Object); - - const result = MediaRecords.update({ - _id: fileRecordId - }, { - $set: { - metadata - } - }); - - return result === 1; -} - /** * @name media/insert * @method @@ -43,13 +20,17 @@ async function updateMediaMetadata(fileRecordId, metadata) { */ export async function insertMedia(fileRecord) { check(fileRecord, Object); - const mediaRecordId = await MediaRecords.insert({ + + const doc = { ...fileRecord, metadata: { ...fileRecord.metadata, workflow: "published" } - }); + }; + const mediaRecordId = await MediaRecords.insert(doc); + + appEvents.emit("afterMediaInsert", doc); return mediaRecordId; } @@ -73,7 +54,13 @@ export async function removeMedia(fileRecordId) { } }); - return result === 1; + const success = (result === 1); + + if (success) { + appEvents.emit("afterMediaUpdate", MediaRecords.findOne({ _id: fileRecordId })); + } + + return success; } /** @@ -116,8 +103,8 @@ export function updateMediaPriorities(sortedMediaIDs) { "metadata.priority": index } }); - const { metadata } = MediaRecords.findOne({ _id }); - updateMediaMetadata(_id, metadata); + + appEvents.emit("afterMediaUpdate", MediaRecords.findOne({ _id })); }); return true; diff --git a/imports/plugins/core/graphql/lib/hocs/withCatalogItems.js b/imports/plugins/core/graphql/lib/hocs/withCatalogItems.js index ce186991a14..b1af7eab150 100644 --- a/imports/plugins/core/graphql/lib/hocs/withCatalogItems.js +++ b/imports/plugins/core/graphql/lib/hocs/withCatalogItems.js @@ -1,3 +1,4 @@ +import { cloneDeep } from "lodash"; import React from "react"; import PropTypes from "prop-types"; import { Query } from "react-apollo"; @@ -8,13 +9,18 @@ import getCatalogItems from "../queries/getCatalogItems"; export default (Component) => ( class CatalogItems extends React.Component { static propTypes = { + currencyCode: PropTypes.string, shopId: PropTypes.string, shouldSkipGraphql: PropTypes.bool, // Whether to skip this HOC's GraphQL query & data tagId: PropTypes.string }; + static defaultProps = { + currencyCode: "USD" + }; + render() { - const { shouldSkipGraphql, shopId, tagId } = this.props; + const { currencyCode, shouldSkipGraphql, shopId, tagId } = this.props; if (shouldSkipGraphql || !shopId) { return ( @@ -22,7 +28,7 @@ export default (Component) => ( ); } - const variables = { shopId }; + const variables = { shopId, currencyCode }; if (tagId) { variables.tagIds = [tagId]; @@ -40,9 +46,18 @@ export default (Component) => ( }; if (loading === false && catalogItems) { - props.catalogItems = (catalogItems.edges || []).map((edge) => edge.node.product); + props.catalogItems = (catalogItems.edges || []).map((edge) => cloneDeep(edge.node.product)); + + // Use prices in selected currency if provided + props.catalogItems.forEach((catalogItem, cIndex) => { + catalogItem.pricing.forEach((pricing, pIndex) => { + if (pricing.currencyExchangePricing) { + props.catalogItems[cIndex].pricing[pIndex] = pricing.currencyExchangePricing; + } + }); + }); - const { pageInfo } = catalogItems; + const { pageInfo } = data.catalogItems; if (pageInfo) { const { hasNextPage } = pageInfo; props.hasMoreCatalogItems = hasNextPage; diff --git a/imports/plugins/core/graphql/lib/hocs/withCatalogItems.test.js b/imports/plugins/core/graphql/lib/hocs/withCatalogItems.test.js index a12e0932592..a97bcf253c1 100644 --- a/imports/plugins/core/graphql/lib/hocs/withCatalogItems.test.js +++ b/imports/plugins/core/graphql/lib/hocs/withCatalogItems.test.js @@ -50,7 +50,17 @@ const fakeCatalogItemsConnection = { }, displayPrice: "$12.99 - $19.99", minPrice: 12.99, - maxPrice: 19.99 + maxPrice: 19.99, + currencyExchangePricing: { + __typename: "ProductPricingInfo", + currency: { + __typename: "Currency", + code: "USD" + }, + displayPrice: "$12.99 - $19.99", + minPrice: 12.99, + maxPrice: 19.99 + } } ], primaryImage: null @@ -68,6 +78,7 @@ const mocks = [ query: getCatalogItems, variables: { shopId: fakeOpaqueShopId, + currencyCode: "USD", tagIds: [fakeOpaqueTagId] } }, @@ -81,7 +92,8 @@ const mocks = [ request: { query: getCatalogItems, variables: { - shopId: "invalidShopId" + shopId: "invalidShopId", + currencyCode: "USD" } }, result: { diff --git a/imports/plugins/core/graphql/lib/queries/getCatalogItems.js b/imports/plugins/core/graphql/lib/queries/getCatalogItems.js index b80e7d8fbeb..48eda42071a 100644 --- a/imports/plugins/core/graphql/lib/queries/getCatalogItems.js +++ b/imports/plugins/core/graphql/lib/queries/getCatalogItems.js @@ -1,7 +1,7 @@ import gql from "graphql-tag"; export default gql` - query getCatalogItems($shopId: ID!, $tagIds: [ID] $first: ConnectionLimitInt, $last: ConnectionLimitInt, + query getCatalogItems($shopId: ID!, $tagIds: [ID], $currencyCode: String!, $first: ConnectionLimitInt, $last: ConnectionLimitInt, $before: ConnectionCursor, $after: ConnectionCursor, $sortBy: CatalogItemSortByField, $sortByPriceCurrencyCode: String, $sortOrder: SortOrder) { catalogItems(shopIds: [$shopId], tagIds: $tagIds, first: $first, last: $last, before: $before, after: $after, @@ -36,6 +36,14 @@ export default gql` currency { code } + currencyExchangePricing(currencyCode: $currencyCode) { + currency { + code + } + displayPrice + minPrice + maxPrice + } displayPrice minPrice maxPrice 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 393278e38b2..0ee02d7cc6a 100644 --- a/imports/plugins/core/graphql/server/no-meteor/xforms/currency.js +++ b/imports/plugins/core/graphql/server/no-meteor/xforms/currency.js @@ -1,7 +1,10 @@ +import { toFixed } from "accounting-js"; import { assoc, compose, map, toPairs } from "ramda"; import ReactionError from "@reactioncommerce/reaction-error"; +import Logger from "@reactioncommerce/logger"; import CurrencyDefinitions from "/imports/plugins/core/core/lib/CurrencyDefinitions"; import { namespaces } from "@reactioncommerce/reaction-graphql-utils"; +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); @@ -45,3 +48,57 @@ 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 { shopId } = context; + const shop = await context.queries.shopById(context, shopId); + + 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.warn("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 14fe83a036f..be9fe5c5176 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 } from "./currency"; +import { xformLegacyCurrencies, xformCurrencyExchangePricing } from "./currency"; const input = { USD: { @@ -14,6 +14,13 @@ const input = { decimal: ",", thousand: ".", rate: 0.812743 + }, + EUR_NO_RATE: { + enabled: true, + format: "%v %s", + symbol: "€", + decimal: ",", + thousand: "." } }; @@ -35,9 +42,72 @@ const expected = [ decimal: ",", thousand: ".", rate: 0.812743 + }, + { + _id: "EUR_NO_RATE", + code: "EUR_NO_RATE", + enabled: true, + format: "%v %s", + symbol: "€", + decimal: ",", + thousand: "." } ]; +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: { + shopById() { + 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/hydra-oauth/server/oauthEndpoints.js b/imports/plugins/core/hydra-oauth/server/oauthEndpoints.js index 11aec5de3d7..7cd5f8fa63d 100644 --- a/imports/plugins/core/hydra-oauth/server/oauthEndpoints.js +++ b/imports/plugins/core/hydra-oauth/server/oauthEndpoints.js @@ -41,18 +41,26 @@ WebApp.connectHandlers.use("/login", (req, res) => { }); WebApp.connectHandlers.use("/consent", (req, res) => { - const challenge = req.query.consent_challenge; // Here, we accept consent directly without presenting a consent form to the user // because this was built for a trusted Consumer client. // For non-trusted Consumer clients, this should be updated to present a Consent UI to // the user grant or deny specific scopes - hydra - .acceptConsentRequest(challenge, { - remember: true, - remember_for: HYDRA_SESSION_LIFESPAN || 3600, // eslint-disable-line camelcase - session: {} // we are not adding any extra user, we use only the sub value already present - }) - .then((consentResponse) => { + const challenge = req.query.consent_challenge; + hydra.getConsentRequest(challenge) + .then(async (response) => { + // eslint-disable-next-line camelcase + const options = { grant_scope: response.requested_scope }; + // if skip is true (i.e no form UI is shown, there's no need to set `remember`) + if (!response.skip) { + // `remember` tells Hydra to remember this consent grant and reuse it if request is from + // the same user on the same client. Ideally, this should be longer than token lifespan. + // Set default is 24 hrs (set in seconds). Depending on preferred setup, you can allow + // users decide if to enable or disable + options.remember = true; + // eslint-disable-next-line camelcase + options.remember_for = HYDRA_SESSION_LIFESPAN ? Number(HYDRA_SESSION_LIFESPAN) : 86400; + } + const consentResponse = await hydra.acceptConsentRequest(challenge, options); Logger.debug(`Consent call complete. Redirecting to: ${consentResponse.redirect_to}`); res.writeHead(301, { Location: consentResponse.redirect_to }); return res.end(); diff --git a/imports/plugins/core/hydra-oauth/server/oauthMethods.js b/imports/plugins/core/hydra-oauth/server/oauthMethods.js index e75be412794..2eeef8118fa 100644 --- a/imports/plugins/core/hydra-oauth/server/oauthMethods.js +++ b/imports/plugins/core/hydra-oauth/server/oauthMethods.js @@ -23,7 +23,12 @@ export function oauthLogin(options) { .acceptLoginRequest(challenge, { subject: Reaction.getUserId(), remember, - remember_for: HYDRA_SESSION_LIFESPAN || 3600 // eslint-disable-line camelcase + // `remember` tells Hydra to remember this login and reuse it if the same user on the same + // client tries to log-in again. Ideally, this should be longer than token lifespan. + // Set default is 24 hrs (set in seconds). Depending on preferred setup, you can allow + // users decide if to enable or disable. + // eslint-disable-next-line camelcase + remember_for: HYDRA_SESSION_LIFESPAN ? Number(HYDRA_SESSION_LIFESPAN) : 86400 }) .then((response) => response.redirect_to) .catch((error) => { diff --git a/imports/plugins/core/orders/server/no-meteor/mutations/createOrder.js b/imports/plugins/core/orders/server/no-meteor/mutations/createOrder.js index 543f50c0ded..2224fa47490 100644 --- a/imports/plugins/core/orders/server/no-meteor/mutations/createOrder.js +++ b/imports/plugins/core/orders/server/no-meteor/mutations/createOrder.js @@ -193,6 +193,7 @@ async function buildOrderItem(inputItem, currencyCode, context) { productId: chosenProduct._id, productSlug: chosenProduct.slug, productType: chosenProduct.type, + productTagIds: chosenProduct.tagIds, productVendor: chosenProduct.vendor, quantity, shopId: chosenProduct.shopId, diff --git a/imports/plugins/core/orders/server/no-meteor/resolvers/OrderItem/index.js b/imports/plugins/core/orders/server/no-meteor/resolvers/OrderItem/index.js index 2b380574b5f..06fe2a312c6 100644 --- a/imports/plugins/core/orders/server/no-meteor/resolvers/OrderItem/index.js +++ b/imports/plugins/core/orders/server/no-meteor/resolvers/OrderItem/index.js @@ -1,7 +1,9 @@ import { encodeOrderItemOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/order"; import { resolveShopFromShopId } from "@reactioncommerce/reaction-graphql-utils"; +import productTags from "./productTags"; export default { _id: (node) => encodeOrderItemOpaqueId(node._id), + productTags, shop: resolveShopFromShopId }; diff --git a/imports/plugins/core/orders/server/no-meteor/resolvers/OrderItem/productTags.js b/imports/plugins/core/orders/server/no-meteor/resolvers/OrderItem/productTags.js new file mode 100644 index 00000000000..c8e483eb867 --- /dev/null +++ b/imports/plugins/core/orders/server/no-meteor/resolvers/OrderItem/productTags.js @@ -0,0 +1,21 @@ +import { getPaginatedResponse } from "@reactioncommerce/reaction-graphql-utils"; +import { xformArrayToConnection } from "@reactioncommerce/reaction-graphql-xforms/connection"; + +/** + * @name "OrderItem.productTags" + * @method + * @memberof Catalog/GraphQL + * @summary Returns the tags for an OrderItem + * @param {Object} orderItem - OrderItem from parent resolver + * @param {TagConnectionArgs} args - arguments sent by the client {@link ConnectionArgs|See default connection arguments} + * @param {Object} context - an object containing the per-request state + * @return {Promise} Promise that resolves with array of Tag objects + */ +export default async function productTags(orderItem, connectionArgs, context) { + const { productTagIds } = orderItem; + if (!productTagIds || productTagIds.length === 0) return xformArrayToConnection(connectionArgs, []); + + const query = await context.queries.tagsByIds(context, productTagIds, connectionArgs); + + return getPaginatedResponse(query, connectionArgs); +} diff --git a/imports/plugins/core/orders/server/no-meteor/schemas/schema.graphql b/imports/plugins/core/orders/server/no-meteor/schemas/schema.graphql index 45691af8e4e..b2d60048428 100644 --- a/imports/plugins/core/orders/server/no-meteor/schemas/schema.graphql +++ b/imports/plugins/core/orders/server/no-meteor/schemas/schema.graphql @@ -105,6 +105,9 @@ type OrderItem implements Node { "The type of product, used to display cart items differently" productType: String + "The list of tags that have been applied to this product" + productTags(after: ConnectionCursor, before: ConnectionCursor, first: ConnectionLimitInt, last: ConnectionLimitInt, sortOrder: SortOrder = asc, sortBy: TagSortByField = _id): TagConnection + "The product vendor" productVendor: String diff --git a/imports/plugins/core/payments/register.js b/imports/plugins/core/payments/register.js index b03b8eae7ea..7857937ec5e 100644 --- a/imports/plugins/core/payments/register.js +++ b/imports/plugins/core/payments/register.js @@ -1,4 +1,6 @@ import Reaction from "/imports/plugins/core/core/server/Reaction"; +import mutations from "./server/no-meteor/mutations"; +import queries from "./server/no-meteor/queries"; import resolvers from "./server/no-meteor/resolvers"; import schemas from "./server/no-meteor/schemas"; @@ -11,6 +13,8 @@ Reaction.registerPackage({ resolvers, schemas }, + queries, + mutations, settings: { payments: { enabled: true diff --git a/imports/plugins/core/payments/server/no-meteor/mutations/__snapshots__/enablePaymentMethodForShop.test.js.snap b/imports/plugins/core/payments/server/no-meteor/mutations/__snapshots__/enablePaymentMethodForShop.test.js.snap new file mode 100644 index 00000000000..22aa864b9b5 --- /dev/null +++ b/imports/plugins/core/payments/server/no-meteor/mutations/__snapshots__/enablePaymentMethodForShop.test.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`errors on invalid payment method 1`] = `"Requested payment method is invalid"`; + +exports[`errors on invalid shop 1`] = `"Shop not found"`; + +exports[`errors on missing arguments 1`] = `"Is enabled is required"`; + +exports[`throws if userHasPermission returns false 1`] = `"The first argument of validate() must be an object"`; diff --git a/imports/plugins/core/payments/server/no-meteor/mutations/enablePaymentMethodForShop.js b/imports/plugins/core/payments/server/no-meteor/mutations/enablePaymentMethodForShop.js new file mode 100644 index 00000000000..82295d23ba3 --- /dev/null +++ b/imports/plugins/core/payments/server/no-meteor/mutations/enablePaymentMethodForShop.js @@ -0,0 +1,52 @@ +import ReactionError from "@reactioncommerce/reaction-error"; +import SimpleSchema from "simpl-schema"; +import { paymentMethods as allPaymentMethods } from "/imports/plugins/core/core/server/no-meteor/pluginRegistration"; + +const paramsSchema = new SimpleSchema({ + isEnabled: Boolean, + paymentMethodName: String, + shopId: String +}); + +/** + * @method enablePaymentMethodForShop + * @summary Enables (or disables) payment method for a given shop + * @param {Object} context - an object containing the per-request state + * @param {Object} input - EnablePaymentMethodForShopInput + * @param {String} input.isEnabled - Whether to enable or disable specified payment method + * @param {String} input.paymentMethodName - The name of the payment method to enable or disable + * @param {String} input.shopId - The id of the shop to enable payment method on + * @param {String} [input.clientMutationId] - An optional string identifying the mutation call + * @return {Promise>} Array + */ +export default async function enablePaymentMethodForShop(context, input = {}) { + paramsSchema.validate(input, { ignore: [SimpleSchema.ErrorTypes.KEY_NOT_IN_SCHEMA] }); + const { Shops } = context.collections; + const { isEnabled, paymentMethodName, shopId } = input; + + if (!context.userHasPermission(["owner", "admin"], shopId)) { + throw new ReactionError("access-denied", "Access denied"); + } + + if (!allPaymentMethods[paymentMethodName]) { + throw new ReactionError("not-found", "Requested payment method is invalid"); + } + + const shop = await context.queries.shopById(context, shopId); + if (!shop) throw new ReactionError("not-found", "Shop not found"); + + const methods = new Set(shop.availablePaymentMethods); + + if (isEnabled) { + methods.add(paymentMethodName); + } else { + methods.delete(paymentMethodName); + } + + await Shops.updateOne( + { _id: shop._id }, + { $set: { availablePaymentMethods: Array.from(methods) } } + ); + + return context.queries.paymentMethods(context, shopId); +} diff --git a/imports/plugins/core/payments/server/no-meteor/mutations/enablePaymentMethodForShop.test.js b/imports/plugins/core/payments/server/no-meteor/mutations/enablePaymentMethodForShop.test.js new file mode 100644 index 00000000000..6a27c6d18c2 --- /dev/null +++ b/imports/plugins/core/payments/server/no-meteor/mutations/enablePaymentMethodForShop.test.js @@ -0,0 +1,118 @@ +import Factory from "/imports/test-utils/helpers/factory"; +import enablePaymentMethodForShop from "./enablePaymentMethodForShop"; +import mockContext from "/imports/test-utils/helpers/mockContext"; + +jest.mock("/imports/plugins/core/core/server/no-meteor/pluginRegistration", () => ({ + paymentMethods: { + mockPaymentMethod: { + name: "mockPaymentMethod", + displayName: "Mock!", + pluginName: "mock-plugin" + } + } +})); + +const fakeShop = Factory.Shop.makeOne(); +const mockEnablePaymentMethod = jest.fn().mockName("enablePaymentMethodForShop"); +const mockPaymentMethods = jest.fn().mockName("paymentMethods"); +const mockShopById = jest.fn().mockName("shopById"); + +beforeAll(() => { + mockContext.queries = { + paymentMethods: mockPaymentMethods, + shopById: mockShopById + }; + mockContext.mutations = { enablePaymentMethodForShop: mockEnablePaymentMethod }; +}); + +beforeEach(() => { + jest.resetAllMocks(); + mockShopById.mockClear(); + mockEnablePaymentMethod.mockClear(); + fakeShop.availablePaymentMethods = []; +}); + +test("throws if userHasPermission returns false", async () => { + mockContext.userHasPermission.mockReturnValue(false); + mockShopById.mockReturnValue(fakeShop); + + await expect(enablePaymentMethodForShop(mockContext, mockContext.shopId)).rejects.toThrowErrorMatchingSnapshot(); +}); + +test("errors on missing arguments", async () => { + mockContext.userHasPermission.mockReturnValue(true); + mockShopById.mockReturnValue(fakeShop); + + await expect(enablePaymentMethodForShop(mockContext, {})).rejects.toThrowErrorMatchingSnapshot(); +}); + +test("errors on invalid payment method", async () => { + mockContext.userHasPermission.mockReturnValue(true); + mockShopById.mockReturnValue(fakeShop); + + await expect(enablePaymentMethodForShop(mockContext, { + shopId: fakeShop._id, + paymentMethodName: "does not exist", + isEnabled: true + })).rejects.toThrowErrorMatchingSnapshot(); +}); + +test("errors on invalid shop", async () => { + mockContext.userHasPermission.mockReturnValue(true); + mockShopById.mockReturnValue(); + + await expect(enablePaymentMethodForShop(mockContext, { + shopId: "does not exist", + paymentMethodName: "mockPaymentMethod", + isEnabled: true + })).rejects.toThrowErrorMatchingSnapshot(); +}); + +test("enables payment method for valid shop", async () => { + fakeShop.availablePaymentMethods = ["mockPaymentMethod"]; + mockContext.userHasPermission.mockReturnValue(true); + mockShopById.mockReturnValue(fakeShop); + mockPaymentMethods.mockReturnValue([{ + name: "mockPaymentMethod", + displayName: "Mock!", + pluginName: "mock-plugin", + isEnabled: true + }]); + + await expect(enablePaymentMethodForShop(mockContext, { + shopId: fakeShop._id, + paymentMethodName: "mockPaymentMethod", + isEnabled: true + })).resolves.toEqual([{ + name: "mockPaymentMethod", + displayName: "Mock!", + pluginName: "mock-plugin", + isEnabled: true + }]); + + expect(mockPaymentMethods).toHaveBeenCalledWith(mockContext, fakeShop._id); +}); + +test("disables payment method for valid shop", async () => { + mockContext.userHasPermission.mockReturnValue(true); + mockShopById.mockReturnValue(fakeShop); + mockPaymentMethods.mockReturnValue([{ + name: "mockPaymentMethod", + displayName: "Mock!", + pluginName: "mock-plugin", + isEnabled: false + }]); + + await expect(enablePaymentMethodForShop(mockContext, { + shopId: fakeShop._id, + paymentMethodName: "mockPaymentMethod", + isEnabled: false + })).resolves.toEqual([{ + name: "mockPaymentMethod", + displayName: "Mock!", + pluginName: "mock-plugin", + isEnabled: false + }]); + + expect(mockPaymentMethods).toHaveBeenCalledWith(mockContext, fakeShop._id); +}); diff --git a/imports/plugins/core/payments/server/no-meteor/mutations/index.js b/imports/plugins/core/payments/server/no-meteor/mutations/index.js new file mode 100644 index 00000000000..78a1b64bd59 --- /dev/null +++ b/imports/plugins/core/payments/server/no-meteor/mutations/index.js @@ -0,0 +1,5 @@ +import enablePaymentMethodForShop from "./enablePaymentMethodForShop"; + +export default { + enablePaymentMethodForShop +}; diff --git a/imports/plugins/core/payments/server/no-meteor/queries/__snapshots__/availablePaymentMethods.test.js.snap b/imports/plugins/core/payments/server/no-meteor/queries/__snapshots__/availablePaymentMethods.test.js.snap new file mode 100644 index 00000000000..37d529d846b --- /dev/null +++ b/imports/plugins/core/payments/server/no-meteor/queries/__snapshots__/availablePaymentMethods.test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`throws if shop not found 1`] = `"Shop not found"`; diff --git a/imports/plugins/core/payments/server/no-meteor/queries/__snapshots__/paymentMethods.test.js.snap b/imports/plugins/core/payments/server/no-meteor/queries/__snapshots__/paymentMethods.test.js.snap new file mode 100644 index 00000000000..d92d3f9438e --- /dev/null +++ b/imports/plugins/core/payments/server/no-meteor/queries/__snapshots__/paymentMethods.test.js.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`throws if shop not found 1`] = `"Shop not found"`; + +exports[`throws if userHasPermission returns false 1`] = `"Access denied"`; diff --git a/imports/plugins/core/payments/server/no-meteor/queries/availablePaymentMethods.js b/imports/plugins/core/payments/server/no-meteor/queries/availablePaymentMethods.js new file mode 100644 index 00000000000..a9107dc2c53 --- /dev/null +++ b/imports/plugins/core/payments/server/no-meteor/queries/availablePaymentMethods.js @@ -0,0 +1,25 @@ +import ReactionError from "@reactioncommerce/reaction-error"; +import { paymentMethods } from "/imports/plugins/core/core/server/no-meteor/pluginRegistration"; + +/** + * @name availablePaymentMethods + * @method + * @memberof Payments/NoMeteorQueries + * @summary get list of all available payment methods for a shop + * @param {Object} context - an object containing the per-request state + * @param {String} shopId - shop id for which to get payment methods + * @return {Array} Array of PaymentMethods + */ +export default async function availablePaymentMethods(context, shopId) { + const shop = await context.queries.shopById(context, shopId); + if (!shop) throw new ReactionError("not-found", "Shop not found"); + const availableMethods = shop.availablePaymentMethods || []; + + return availableMethods + .reduce((all, name) => { + if (paymentMethods[name]) { + all.push({ ...paymentMethods[name], isEnabled: true }); + } + return all; + }, []); +} diff --git a/imports/plugins/core/payments/server/no-meteor/queries/availablePaymentMethods.test.js b/imports/plugins/core/payments/server/no-meteor/queries/availablePaymentMethods.test.js new file mode 100644 index 00000000000..0f0fd0d27f2 --- /dev/null +++ b/imports/plugins/core/payments/server/no-meteor/queries/availablePaymentMethods.test.js @@ -0,0 +1,70 @@ +import Factory from "/imports/test-utils/helpers/factory"; +import mockContext from "/imports/test-utils/helpers/mockContext"; +import query from "./availablePaymentMethods"; + +jest.mock("/imports/plugins/core/core/server/no-meteor/pluginRegistration", () => ({ + paymentMethods: { + mockPaymentMethod: { + name: "mockPaymentMethod", + displayName: "Mock!", + pluginName: "mock-plugin" + } + } +})); + +const fakeShop = Factory.Shop.makeOne(); +const mockShopById = jest.fn().mockName("shopById"); + +beforeAll(() => { + mockContext.queries = { + shopById: mockShopById + }; +}); + +beforeEach(() => { + jest.resetAllMocks(); + mockShopById.mockClear(); + fakeShop.availablePaymentMethods = []; +}); + +test("throws if shop not found", async () => { + mockContext.userHasPermission.mockReturnValueOnce(true); + mockShopById.mockReturnValueOnce(); + + await expect(query(mockContext, "nonexistent-shop-id")).rejects.toThrowErrorMatchingSnapshot(); + expect(mockShopById).toHaveBeenCalledWith(mockContext, "nonexistent-shop-id"); +}); + +test("returns empty array when shop has no payment methods", async () => { + mockShopById.mockReturnValueOnce(fakeShop); + mockContext.userHasPermission.mockReturnValueOnce(true); + + const result = await query(mockContext, mockContext.shopId); + expect(mockShopById).toHaveBeenCalledWith(mockContext, mockContext.shopId); + expect(result).toEqual([]); +}); + +test("returns empty array when shop has no valid payment methods", async () => { + mockShopById.mockReturnValueOnce(fakeShop); + mockContext.userHasPermission.mockReturnValueOnce(true); + fakeShop.availablePaymentMethods.push("nonexistent-payment-method"); + + const result = await query(mockContext, mockContext.shopId); + expect(mockShopById).toHaveBeenCalledWith(mockContext, mockContext.shopId); + expect(result).toEqual([]); +}); + +test("returns available payment methods for a shop", async () => { + mockShopById.mockReturnValueOnce(fakeShop); + mockContext.userHasPermission.mockReturnValueOnce(true); + fakeShop.availablePaymentMethods.push("mockPaymentMethod"); + + const result = await query(mockContext, mockContext.shopId); + expect(mockShopById).toHaveBeenCalledWith(mockContext, mockContext.shopId); + expect(result).toEqual([{ + name: "mockPaymentMethod", + displayName: "Mock!", + pluginName: "mock-plugin", + isEnabled: true + }]); +}); diff --git a/imports/plugins/core/payments/server/no-meteor/queries/index.js b/imports/plugins/core/payments/server/no-meteor/queries/index.js new file mode 100644 index 00000000000..914093fb929 --- /dev/null +++ b/imports/plugins/core/payments/server/no-meteor/queries/index.js @@ -0,0 +1,7 @@ +import availablePaymentMethods from "./availablePaymentMethods"; +import paymentMethods from "./paymentMethods"; + +export default { + availablePaymentMethods, + paymentMethods +}; diff --git a/imports/plugins/core/payments/server/no-meteor/queries/paymentMethods.js b/imports/plugins/core/payments/server/no-meteor/queries/paymentMethods.js new file mode 100644 index 00000000000..aa3ec2e3e07 --- /dev/null +++ b/imports/plugins/core/payments/server/no-meteor/queries/paymentMethods.js @@ -0,0 +1,27 @@ +import ReactionError from "@reactioncommerce/reaction-error"; +import { paymentMethods as allPaymentMethods } from "/imports/plugins/core/core/server/no-meteor/pluginRegistration"; + +/** + * @name paymentMethods + * @method + * @memberof Payments/NoMeteorQueries + * @summary get list of all registered payment methods for a shop + * @param {Object} context - an object containing the per-request state + * @param {String} shopId - shop id for which to get payment methods + * @return {Array} Array of PaymentMethods + */ +export default async function paymentMethods(context, shopId) { + const shop = await context.queries.shopById(context, shopId); + if (!shop) throw new ReactionError("not-found", "Shop not found"); + const availablePaymentMethods = shop.availablePaymentMethods || []; + + if (!context.userHasPermission(["owner", "admin"], shopId)) { + throw new ReactionError("access-denied", "Access denied"); + } + + return Object.keys(allPaymentMethods) + .map((name) => ({ + ...allPaymentMethods[name], + isEnabled: availablePaymentMethods.includes(name) + })); +} diff --git a/imports/plugins/core/payments/server/no-meteor/queries/paymentMethods.test.js b/imports/plugins/core/payments/server/no-meteor/queries/paymentMethods.test.js new file mode 100644 index 00000000000..1d914e6b6a4 --- /dev/null +++ b/imports/plugins/core/payments/server/no-meteor/queries/paymentMethods.test.js @@ -0,0 +1,74 @@ +import Factory from "/imports/test-utils/helpers/factory"; +import mockContext from "/imports/test-utils/helpers/mockContext"; +import query from "./paymentMethods"; + +jest.mock("/imports/plugins/core/core/server/no-meteor/pluginRegistration", () => ({ + paymentMethods: { + mockPaymentMethod: { + name: "mockPaymentMethod", + displayName: "Mock!", + pluginName: "mock-plugin" + } + } +})); + +const fakeShop = Factory.Shop.makeOne(); +const mockShopById = jest.fn().mockName("shopById"); + +beforeAll(() => { + mockContext.queries = { + shopById: mockShopById + }; +}); + +beforeEach(() => { + jest.resetAllMocks(); + mockShopById.mockClear(); + fakeShop.availablePaymentMethods = []; +}); + +test("throws if userHasPermission returns false", async () => { + mockContext.userHasPermission.mockReturnValueOnce(false); + mockShopById.mockReturnValueOnce(fakeShop); + + await expect(query(mockContext, mockContext.shopId)).rejects.toThrowErrorMatchingSnapshot(); + expect(mockShopById).toHaveBeenCalledWith(mockContext, mockContext.shopId); + expect(mockContext.userHasPermission).toHaveBeenCalledWith(["owner", "admin"], mockContext.shopId); +}); + +test("throws if shop not found", async () => { + mockContext.userHasPermission.mockReturnValueOnce(true); + mockShopById.mockReturnValueOnce(); + + await expect(query(mockContext, "nonexistent-shop-id")).rejects.toThrowErrorMatchingSnapshot(); + expect(mockShopById).toHaveBeenCalledWith(mockContext, "nonexistent-shop-id"); +}); + +test("returns all payment methods for a shop", async () => { + mockContext.userHasPermission.mockReturnValueOnce(true); + mockShopById.mockReturnValueOnce(fakeShop); + + const result = await query(mockContext, mockContext.shopId); + expect(mockShopById).toHaveBeenCalledWith(mockContext, mockContext.shopId); + expect(result).toEqual([{ + name: "mockPaymentMethod", + displayName: "Mock!", + pluginName: "mock-plugin", + isEnabled: false + }]); +}); + +test("returns payment methods with correct enabled status", async () => { + mockContext.userHasPermission.mockReturnValueOnce(true); + mockShopById.mockReturnValueOnce(fakeShop); + fakeShop.availablePaymentMethods.push("mockPaymentMethod"); + + const result = await query(mockContext, mockContext.shopId); + expect(mockShopById).toHaveBeenCalledWith(mockContext, mockContext.shopId); + expect(result).toEqual([{ + name: "mockPaymentMethod", + displayName: "Mock!", + pluginName: "mock-plugin", + isEnabled: true + }]); +}); diff --git a/imports/plugins/core/payments/server/no-meteor/resolvers/Mutation/enablePaymentMethodForShop.js b/imports/plugins/core/payments/server/no-meteor/resolvers/Mutation/enablePaymentMethodForShop.js new file mode 100644 index 00000000000..1d906cf7104 --- /dev/null +++ b/imports/plugins/core/payments/server/no-meteor/resolvers/Mutation/enablePaymentMethodForShop.js @@ -0,0 +1,28 @@ +import { decodeShopOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/shop"; + +/** + * @name "Mutation.enablePaymentMethodForShop" + * @method + * @memberof Payment/GraphQL + * @summary resolver for the enablePaymentMethodForShop GraphQL mutation + * @param {Object} parentResult - unused + * @param {Object} args.input - EnablePaymentMethodForShopInput + * @param {String} args.input.isEnabled - Whether to enable or disable specified payment method + * @param {String} args.input.paymentMethodName - The name of the payment method to enable or disable + * @param {String} args.input.shopId - The id of the shop to enable payment method on + * @param {String} [args.input.clientMutationId] - An optional string identifying the mutation call + * @param {Object} context - an object containing the per-request state + * @return {Promise>} EnablePaymentMethodForShopPayload + */ +export default async function enablePaymentMethodForShop(parentResult, { input }, context) { + const { clientMutationId } = input; + const paymentMethods = await context.mutations.enablePaymentMethodForShop(context, { + ...input, + shopId: decodeShopOpaqueId(input.shopId) + }); + + return { + clientMutationId, + paymentMethods + }; +} diff --git a/imports/plugins/core/payments/server/no-meteor/resolvers/Mutation/enablePaymentMethodForShop.test.js b/imports/plugins/core/payments/server/no-meteor/resolvers/Mutation/enablePaymentMethodForShop.test.js new file mode 100644 index 00000000000..78850dca4a0 --- /dev/null +++ b/imports/plugins/core/payments/server/no-meteor/resolvers/Mutation/enablePaymentMethodForShop.test.js @@ -0,0 +1,35 @@ +import enablePaymentMethodForShop from "./enablePaymentMethodForShop"; + +const internalShopId = "555"; +const opaqueShopId = "cmVhY3Rpb24vc2hvcDo1NTU="; + +test("correctly passes through to mutations.enablePaymentMethodForShop", async () => { + const fakeResult = { + shop: { _id: "123" } + }; + + const mockMutation = jest.fn().mockName("mutations.enablePaymentMethodForShop"); + mockMutation.mockReturnValueOnce(fakeResult); + const context = { + mutations: { + enablePaymentMethodForShop: mockMutation + } + }; + + const result = await enablePaymentMethodForShop(null, { + input: { + shopId: opaqueShopId, + clientMutationId: "clientMutationId" + } + }, context); + + expect(result).toEqual({ + paymentMethods: { ...fakeResult }, + clientMutationId: "clientMutationId" + }); + + expect(mockMutation).toHaveBeenCalledWith(context, { + shopId: internalShopId, + clientMutationId: "clientMutationId" + }); +}); diff --git a/imports/plugins/core/payments/server/no-meteor/resolvers/Mutation/index.js b/imports/plugins/core/payments/server/no-meteor/resolvers/Mutation/index.js new file mode 100644 index 00000000000..78a1b64bd59 --- /dev/null +++ b/imports/plugins/core/payments/server/no-meteor/resolvers/Mutation/index.js @@ -0,0 +1,5 @@ +import enablePaymentMethodForShop from "./enablePaymentMethodForShop"; + +export default { + enablePaymentMethodForShop +}; diff --git a/imports/plugins/core/payments/server/no-meteor/resolvers/Query/availablePaymentMethods.js b/imports/plugins/core/payments/server/no-meteor/resolvers/Query/availablePaymentMethods.js new file mode 100644 index 00000000000..1b8b591d5f4 --- /dev/null +++ b/imports/plugins/core/payments/server/no-meteor/resolvers/Query/availablePaymentMethods.js @@ -0,0 +1,17 @@ +import { decodeShopOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/shop"; + +/** + * @name Query.availablePaymentMethods + * @method + * @memberof Payment/GraphQL + * @summary get all available payment methods for a given shop + * @param {Object} _ - unused + * @param {Object} args - an object of all arguments that were sent by the client + * @param {String} args.shopId - shop id for which to get payment methods + * @param {Object} context - an object containing the per-request state + * @return {Promise>} Array of PaymentMethods + */ +export default async function availablePaymentMethods(_, { shopId }, context) { + const dbShopId = decodeShopOpaqueId(shopId); + return context.queries.availablePaymentMethods(context, dbShopId); +} diff --git a/imports/plugins/core/payments/server/no-meteor/resolvers/Query/index.js b/imports/plugins/core/payments/server/no-meteor/resolvers/Query/index.js new file mode 100644 index 00000000000..914093fb929 --- /dev/null +++ b/imports/plugins/core/payments/server/no-meteor/resolvers/Query/index.js @@ -0,0 +1,7 @@ +import availablePaymentMethods from "./availablePaymentMethods"; +import paymentMethods from "./paymentMethods"; + +export default { + availablePaymentMethods, + paymentMethods +}; diff --git a/imports/plugins/core/payments/server/no-meteor/resolvers/Query/paymentMethods.js b/imports/plugins/core/payments/server/no-meteor/resolvers/Query/paymentMethods.js new file mode 100644 index 00000000000..96ad365c224 --- /dev/null +++ b/imports/plugins/core/payments/server/no-meteor/resolvers/Query/paymentMethods.js @@ -0,0 +1,17 @@ +import { decodeShopOpaqueId } from "@reactioncommerce/reaction-graphql-xforms/shop"; + +/** + * @name Query.paymentMethods + * @method + * @memberof Payment/GraphQL + * @summary get all available payment methods for a given shop + * @param {Object} _ - unused + * @param {Object} args - an object of all arguments that were sent by the client + * @param {String} args.shopId - shop id for which to get payment methods + * @param {Object} context - an object containing the per-request state + * @return {Promise>} Array of PaymentMethods + */ +export default async function paymentMethods(_, { shopId }, context) { + const dbShopId = decodeShopOpaqueId(shopId); + return context.queries.paymentMethods(context, dbShopId); +} diff --git a/imports/plugins/core/payments/server/no-meteor/resolvers/index.js b/imports/plugins/core/payments/server/no-meteor/resolvers/index.js index a97b096a043..05312af4ab1 100644 --- a/imports/plugins/core/payments/server/no-meteor/resolvers/index.js +++ b/imports/plugins/core/payments/server/no-meteor/resolvers/index.js @@ -1,5 +1,9 @@ +import Mutation from "./Mutation"; import Payment from "./Payment"; +import Query from "./Query"; export default { - Payment + Mutation, + Payment, + Query }; diff --git a/imports/plugins/core/payments/server/no-meteor/schemas/schema.graphql b/imports/plugins/core/payments/server/no-meteor/schemas/schema.graphql index 6df9e396d16..408f8aabfa0 100644 --- a/imports/plugins/core/payments/server/no-meteor/schemas/schema.graphql +++ b/imports/plugins/core/payments/server/no-meteor/schemas/schema.graphql @@ -10,6 +10,10 @@ extend type Query { paymentMethods(shopId: ID!): [PaymentMethod]! } +extend type Mutation { + enablePaymentMethodForShop(input: EnablePaymentMethodForShopInput!): EnablePaymentMethodForShopPayload! +} + """ Information about a payment made """ @@ -57,7 +61,6 @@ union PaymentData = ExampleIOUPaymentData | StripeCardPaymentData | MarketplaceS # These should be defined in their respective plugin schemas, but `extend enum` isn't working yet "The name of a payment method, which is how payment methods are keyed" enum PaymentMethodName { - none iou_example stripe_card } @@ -74,6 +77,39 @@ type PaymentMethod { "Data for this method. The data format differs for each method" data: PaymentMethodData + "Human-readable display name" + displayName: String! + + "Whether the payment method is enabled on a given shop" + isEnabled: Boolean! + "The payment method name. Any valid name that has been registered by a payment plugin. e.g., saved_card" - name: PaymentMethodName! + # TODO: this being a string is temporary until extend works for enums, at which point we'll use PaymentMethodName + name: String! + + "Name of the plugin that added the payment method" + pluginName: String! +} + +"Input for the `enablePaymentMethodForShop` mutation" +input EnablePaymentMethodForShopInput { + "An optional string identifying the mutation call, which will be returned in the response payload" + clientMutationId: String + + "True to enable it or false to disable it" + isEnabled: Boolean! + + "The name of the payment method to enable or disable" + paymentMethodName: String! + + "The ID of the shop for which this payment method should be enabled or disabled" + shopId: ID! +} + +type EnablePaymentMethodForShopPayload { + "The same string you sent with the mutation params, for matching mutation calls with their responses" + clientMutationId: String + + "The full list of payment methods for the shop" + paymentMethods: [PaymentMethod]! } diff --git a/imports/plugins/core/router/client/appComponents.js b/imports/plugins/core/router/client/appComponents.js index dee928ec382..3102643be08 100644 --- a/imports/plugins/core/router/client/appComponents.js +++ b/imports/plugins/core/router/client/appComponents.js @@ -1,11 +1,16 @@ import React from "react"; +import BadgeOverlay from "@reactioncommerce/components/BadgeOverlay/v1"; import Button from "@reactioncommerce/components/Button/v1"; +import CatalogGrid from "@reactioncommerce/components/CatalogGrid/v1"; +import CatalogGridItem from "@reactioncommerce/components/CatalogGridItem/v1"; import ErrorsBlock from "@reactioncommerce/components/ErrorsBlock/v1"; import Field from "@reactioncommerce/components/Field/v1"; +import { Link } from "/imports/plugins/core/ui/client/components/link"; import PhoneNumberInput from "@reactioncommerce/components/PhoneNumberInput/v1"; import Price from "@reactioncommerce/components/Price/v1"; +import ProgressiveImage from "@reactioncommerce/components/ProgressiveImage/v1"; import Select from "@reactioncommerce/components/Select/v1"; -import spinner from "@reactioncommerce/components/utils/spinner"; +import spinner from "@reactioncommerce/components/svg/spinner"; import TextInput from "@reactioncommerce/components/TextInput/v1"; /* eslint-disable max-len */ @@ -63,14 +68,19 @@ const iconValid = ( /* eslint-enable max-len */ export default { + BadgeOverlay, Button, + CatalogGrid, + CatalogGridItem, ErrorsBlock, Field, + Link, iconClear, iconError, iconValid, PhoneNumberInput, Price, + ProgressiveImage, Select, spinner, TextInput diff --git a/imports/plugins/core/ui/client/components/index.js b/imports/plugins/core/ui/client/components/index.js index 1781d1bb3ee..31d4525430d 100644 --- a/imports/plugins/core/ui/client/components/index.js +++ b/imports/plugins/core/ui/client/components/index.js @@ -11,6 +11,7 @@ export { default as NumericInput } from "./numericInput/numericInput"; export { default as NumberTypeInput } from "./numericInput/numberTypeInput"; export { Button, IconButton, EditButton, VisibilityButton, Handle, ButtonSelect } from "./button"; export { Badge } from "./badge"; +export { Link } from "./link"; export { Translation, Currency } from "./translation"; export { default as Tooltip } from "./tooltip/tooltip"; export { Metadata, Metafield } from "./metadata"; diff --git a/imports/plugins/core/ui/client/components/link/index.js b/imports/plugins/core/ui/client/components/link/index.js new file mode 100644 index 00000000000..a1800f6fe62 --- /dev/null +++ b/imports/plugins/core/ui/client/components/link/index.js @@ -0,0 +1 @@ +export { default as Link } from "./link"; diff --git a/imports/plugins/core/ui/client/components/link/link.js b/imports/plugins/core/ui/client/components/link/link.js new file mode 100644 index 00000000000..522fb2aa753 --- /dev/null +++ b/imports/plugins/core/ui/client/components/link/link.js @@ -0,0 +1,36 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +/** + * @file + * Link is a drop-in replacement for the HTML tag. It uses Reaction's router to navigate to the specified href, + * preventing the entire client app from reloading. It also scrolls to the top of the next page. + * + * @module Link + * @extends Component + */ + +export default class Link extends Component { + static propTypes = { + href: PropTypes.string.isRequired + }; + + handleClick = (event) => { + event.preventDefault(); + this.props.onClick(event); + ReactionRouter.go(this.props.href); // eslint-disable-line no-undef + + if (typeof window !== "undefined" && typeof window.scrollTo === "function") { + window.scrollTo(0, 0); + } + }; + + render() { + const { href, children } = this.props; // eslint-disable-line react/prop-types + return ( + + {children} + + ); + } +} diff --git a/imports/plugins/core/versions/server/migrations/24_publish_all_existing_visible_products.js b/imports/plugins/core/versions/server/migrations/24_publish_all_existing_visible_products.js index 8555a99d634..0b29a33f355 100644 --- a/imports/plugins/core/versions/server/migrations/24_publish_all_existing_visible_products.js +++ b/imports/plugins/core/versions/server/migrations/24_publish_all_existing_visible_products.js @@ -16,7 +16,7 @@ Migrations.add({ let success = false; try { - success = Promise.await(publishProductsToCatalog(visiblePublishedProducts, collections)); + success = Promise.await(publishProductsToCatalog(visiblePublishedProducts, { collections, getFunctionsOfType: () => [] })); } catch (error) { Logger.error("Error in migration 24, publishProductsToCatalog", error); } diff --git a/imports/plugins/core/versions/server/migrations/28_add_hash_to_products.js b/imports/plugins/core/versions/server/migrations/28_add_hash_to_products.js index 7a3012d7f45..68ef54f91c2 100644 --- a/imports/plugins/core/versions/server/migrations/28_add_hash_to_products.js +++ b/imports/plugins/core/versions/server/migrations/28_add_hash_to_products.js @@ -2,17 +2,17 @@ import Logger from "@reactioncommerce/logger"; import { Migrations } from "meteor/percolate:migrations"; import { Catalog } from "/lib/collections"; import collections from "/imports/collections/rawCollections"; -import hashProduct from "/imports/plugins/core/catalog/server/no-meteor/mutations/hashProduct"; +import hashProduct from "../util/hashProduct"; Migrations.add({ version: 28, up() { - const products = Catalog.find({ + const catalogItems = Catalog.find({ "product.type": "product-simple" }).fetch(); try { - products.forEach((product) => Promise.await(hashProduct(product.product._id, collections))); + catalogItems.forEach((catalogItem) => Promise.await(hashProduct(catalogItem.product._id, collections))); } catch (error) { Logger.error("Error in migration 28, hashProduct", error); } diff --git a/imports/plugins/core/versions/server/migrations/32_publish_all_existing_visible_products.js b/imports/plugins/core/versions/server/migrations/32_publish_all_existing_visible_products.js index 0c685af257d..0379d2ba060 100644 --- a/imports/plugins/core/versions/server/migrations/32_publish_all_existing_visible_products.js +++ b/imports/plugins/core/versions/server/migrations/32_publish_all_existing_visible_products.js @@ -15,7 +15,7 @@ Migrations.add({ }, { _id: 1 }).map((product) => product._id); let success = false; try { - success = Promise.await(publishProductsToCatalog(visiblePublishedProducts, collections)); + success = Promise.await(publishProductsToCatalog(visiblePublishedProducts, { collections, getFunctionsOfType: () => [] })); } catch (error) { Logger.error("Error in migration 32, publishProductsToCatalog", error); } diff --git a/imports/plugins/core/versions/server/migrations/41_set_should_appear_in_sitemap.js b/imports/plugins/core/versions/server/migrations/41_set_should_appear_in_sitemap.js new file mode 100644 index 00000000000..fc0201b5bce --- /dev/null +++ b/imports/plugins/core/versions/server/migrations/41_set_should_appear_in_sitemap.js @@ -0,0 +1,31 @@ +import { Migrations } from "meteor/percolate:migrations"; +import { Products } from "/lib/collections"; + +/** + * @file + * Updates all existing products to set shouldAppearInSitemap to true + */ + +const productsSelector = { type: "simple" }; + +Migrations.add({ + version: 41, + up() { + Products.rawCollection().update(productsSelector, { + $set: { + shouldAppearInSitemap: true + } + }, { + multi: true + }); + }, + down() { + Products.rawCollection().update(productsSelector, { + $unset: { + shouldAppearInSitemap: "" + } + }, { + multi: true + }); + } +}); diff --git a/imports/plugins/core/versions/server/migrations/index.js b/imports/plugins/core/versions/server/migrations/index.js index 4b5c1dbe483..53232225d3d 100644 --- a/imports/plugins/core/versions/server/migrations/index.js +++ b/imports/plugins/core/versions/server/migrations/index.js @@ -38,3 +38,4 @@ import "./37_change_shipping_rate_settings_template_name"; import "./38_registry_products_template"; import "./39_example_payment_template"; import "./40_two_point_oh"; +import "./41_set_should_appear_in_sitemap"; diff --git a/imports/plugins/core/versions/server/util/hashProduct.js b/imports/plugins/core/versions/server/util/hashProduct.js new file mode 100644 index 00000000000..26e11f1041a --- /dev/null +++ b/imports/plugins/core/versions/server/util/hashProduct.js @@ -0,0 +1,121 @@ +import hash from "object-hash"; +import Logger from "@reactioncommerce/logger"; + +const productFieldsThatNeedPublishing = [ + "_id", + "ancestors", + "description", + "facebookMsg", + "googleplusMsg", + "handle", + "hashtags", + "isDeleted", + "isVisible", + "media", + "metaDescription", + "metafields", + "originCountry", + "pageTitle", + "parcel", + "pinterestMsg", + "productType", + "price", + "pricing", + "publishedScope", + "shopId", + "supportedFulfillmentTypes", + "template", + "title", + "twitterMsg", + "type", + "variants", + "vendor" +]; + +/** + * + * @method getTopLevelProduct + * @summary Get a top level product based on provided ID + * @param {String} productOrVariantId - A variant or top level Product Variant ID. + * @param {Object} collections - Raw mongo collections. + * @return {Promise} Top level product object. + */ +async function getTopLevelProduct(productOrVariantId, collections) { + const { Products } = collections; + + // Find a product or variant + let product = await Products.findOne({ + _id: productOrVariantId + }); + + // If the found product has ancestors, + // then attempt to find the top-level product + if (product && Array.isArray(product.ancestors) && product.ancestors.length) { + product = await Products.findOne({ + _id: product.ancestors[0] + }); + } + + return product; +} + +/** + * @method createProductHash + * @summary Create a hash of a product to compare for updates + * @memberof Catalog + * @param {String} product - The Product document to hash + * @return {String} product hash + */ +function createProductHash(product) { + const productForHashing = {}; + productFieldsThatNeedPublishing.forEach((field) => { + productForHashing[field] = product[field]; + }); + + return hash(productForHashing); +} + +/** + * @method hashProduct + * @summary Create a hash of a product to compare for updates + * @memberof Catalog + * @param {String} productId - A productId + * @param {Object} collections - Raw mongo collections + * @param {Boolean} isPublished - Is product published to catalog + * @return {Object} updated product if successful, original product if unsuccessful + */ +export default async function hashProduct(productId, collections, isPublished = true) { + const { Products } = collections; + + const product = await getTopLevelProduct(productId, collections); + + const productHash = createProductHash(product); + + // Insert/update product document with hash field + const hashFields = { + currentProductHash: productHash + }; + + if (isPublished) { + hashFields.publishedProductHash = productHash; + } + + const result = await Products.updateOne( + { + _id: product._id + }, + { + $set: { + ...hashFields, + updatedAt: new Date() + } + } + ); + + if (!result || !result.result || result.result.ok !== 1) { + Logger.error(result && result.result); + throw new Error(`Failed to update product hashes for product with ID ${product._id}`); + } + + return null; +} diff --git a/imports/plugins/included/default-theme/client/styles/accounts/accounts.less b/imports/plugins/included/default-theme/client/styles/accounts/accounts.less index fd0d3a6e4e8..f4c11bb19dc 100644 --- a/imports/plugins/included/default-theme/client/styles/accounts/accounts.less +++ b/imports/plugins/included/default-theme/client/styles/accounts/accounts.less @@ -16,7 +16,6 @@ } .accounts-dialog { - width: @accounts-dialog-width; diff --git a/imports/plugins/included/default-theme/client/styles/base.less b/imports/plugins/included/default-theme/client/styles/base.less index d2522a49edf..77ae6cf3e92 100644 --- a/imports/plugins/included/default-theme/client/styles/base.less +++ b/imports/plugins/included/default-theme/client/styles/base.less @@ -24,6 +24,25 @@ main { } } +.container-grid { + position: relative; + z-index: 100; + box-sizing: content-box; + padding: 20px 55px 20px 55px; + + &.search { + padding-right: 40px; + } + + @media only screen and (max-width: @screen-sm-max) { + padding: 20px 12px 20px 12px; + + &.search { + padding-right: 12px; + } + } +} + /* Fix for hidden modals */ .modal-dialog { z-index: 9999; diff --git a/imports/plugins/included/default-theme/client/styles/search/results.less b/imports/plugins/included/default-theme/client/styles/search/results.less index f8f27e79a8d..476492a82c2 100644 --- a/imports/plugins/included/default-theme/client/styles/search/results.less +++ b/imports/plugins/included/default-theme/client/styles/search/results.less @@ -2,13 +2,12 @@ .rui.search-modal { width: 100%; height: 100%; - background-color: fade(@body-bg, 95%); + background-color: @body-bg; position: absolute; top: 0; z-index: @zindex-modal; padding: 0; margin: 0; - padding-top: 250px; } /* -------------------------- Close Modal Button -------------------------- */ @@ -23,9 +22,9 @@ .rui.search-modal-header { width: 100%; padding-top: 40px; - padding-bottom: 40px; + padding-bottom: 20px; background: @white; - position:fixed; + position: fixed; top: 0; display: -webkit-box; display: -moz-box; @@ -36,6 +35,7 @@ flex-direction: column; justify-content: center; transition: background-color 200ms linear; + z-index: 200; .active-search { background-color: @white; @@ -112,7 +112,7 @@ padding-bottom: 20px; padding-left: 20px; background-color: @black05; - overflow-y: scroll; + overflow-x: scroll; } .rui.suggested-tags { font-size: 12px; @@ -170,8 +170,12 @@ /* -------------------------- Modal Container -------------------------- */ .rui.search-modal-results-container { width: 100%; - height: 75vh; - overflow: scroll; + height: auto; + overflow-y: scroll; + + .data-table { + margin-top: 20px; + } @media @mobile { width: 100%; diff --git a/imports/plugins/included/marketplace/register.js b/imports/plugins/included/marketplace/register.js index aa32061cb22..f2631ec782c 100644 --- a/imports/plugins/included/marketplace/register.js +++ b/imports/plugins/included/marketplace/register.js @@ -11,6 +11,10 @@ Reaction.registerPackage({ resolvers, schemas }, + paymentMethods: [{ + name: "marketplace_stripe_card", + displayName: "Marketplace Stripe Card" + }], settings: { name: "Marketplace", enabled: true, diff --git a/imports/plugins/included/marketplace/server/no-meteor/util/createSingleCharge.js b/imports/plugins/included/marketplace/server/no-meteor/util/createSingleCharge.js index de79b849e32..0c5a6b2d216 100644 --- a/imports/plugins/included/marketplace/server/no-meteor/util/createSingleCharge.js +++ b/imports/plugins/included/marketplace/server/no-meteor/util/createSingleCharge.js @@ -2,7 +2,7 @@ import Random from "@reactioncommerce/random"; const METHOD = "credit"; const PACKAGE_NAME = "reaction-stripe"; -const PAYMENT_METHOD_NAME = "stripe_card"; +const PAYMENT_METHOD_NAME = "marketplace_stripe_card"; // NOTE: The "processor" value is lowercased and then prefixed to various payment Meteor method names, // so for example, if this is "Stripe", the list refunds method is expected to be named "stripe/refund/list" diff --git a/imports/plugins/included/notifications/server/no-meteor/createNotification.js b/imports/plugins/included/notifications/server/no-meteor/createNotification.js index 51dac408d04..404f783824a 100644 --- a/imports/plugins/included/notifications/server/no-meteor/createNotification.js +++ b/imports/plugins/included/notifications/server/no-meteor/createNotification.js @@ -20,14 +20,15 @@ const messageForType = { * @param {String} input.details - details of the Notification * @param {String} input.type - The type of Notification * @param {String} input.url - url link + * @param {String} [input.message] - Message to send, if not already defined in messageForType * @return {undefined} */ -export default async function createNotification(collections, { accountId, details, type, url }) { +export default async function createNotification(collections, { accountId, details, type, url, message = "" }) { const doc = { _id: Random.id(), details, hasDetails: !!details, - message: messageForType[type], + message: message || messageForType[type], status: "unread", timeSent: new Date(), to: accountId, diff --git a/imports/plugins/included/payments-example/register.js b/imports/plugins/included/payments-example/register.js index 73a388b42c1..eaef25e48b0 100644 --- a/imports/plugins/included/payments-example/register.js +++ b/imports/plugins/included/payments-example/register.js @@ -12,6 +12,10 @@ Reaction.registerPackage({ resolvers, schemas }, + paymentMethods: [{ + name: "iou_example", + displayName: "IOU Example" + }], settings: { "mode": false, "apiKey": "", diff --git a/imports/plugins/included/payments-stripe/register.js b/imports/plugins/included/payments-stripe/register.js index cf8d2439c3d..ec0f28cb79a 100644 --- a/imports/plugins/included/payments-stripe/register.js +++ b/imports/plugins/included/payments-stripe/register.js @@ -12,6 +12,10 @@ Reaction.registerPackage({ resolvers, schemas }, + paymentMethods: [{ + name: "stripe_card", + displayName: "Stripe Card" + }], settings: { "mode": false, "api_key": "", diff --git a/imports/plugins/included/product-admin/client/components/productAdmin.js b/imports/plugins/included/product-admin/client/components/productAdmin.js index 01bfee11a02..01bbb3dc761 100644 --- a/imports/plugins/included/product-admin/client/components/productAdmin.js +++ b/imports/plugins/included/product-admin/client/components/productAdmin.js @@ -1,10 +1,12 @@ import { isEqual } from "lodash"; import React, { Component } from "react"; import PropTypes from "prop-types"; +import Alert from "sweetalert2"; import { Components } from "@reactioncommerce/reaction-components"; -import { Router } from "/client/api"; +import { i18next, Router } from "/client/api"; import update from "immutability-helper"; import { highlightInput } from "/imports/plugins/core/ui/client/helpers/animations"; +import withGenerateSitemaps from "/imports/plugins/included/sitemap-generator/client/hocs/withGenerateSitemaps"; const fieldNames = [ "title", @@ -145,6 +147,41 @@ class ProductAdmin extends Component { } } + handleSitemapCheckboxChange = (event) => { + const { checked: isChecked } = event.target; + const { shouldAppearInSitemap } = this.product; + if (typeof shouldAppearInSitemap === "undefined" || isChecked === shouldAppearInSitemap) { + // onChange for checkbox runs when field is first displayed + return; + } + + if (this.props.onProductFieldSave) { + this.props.onProductFieldSave(this.product._id, "shouldAppearInSitemap", isChecked); + } + + const { isVisible, isDeleted } = this.product; + if (isVisible && !isDeleted) { + // If product is published, ask whether to regenerate sitemap + Alert({ + title: i18next.t("productDetailEdit.refreshSitemap", { defaultValue: "Refresh sitemap now?" }), + type: "warning", + showCancelButton: true, + cancelButtonText: i18next.t("productDetailEdit.refreshSitemapNo", { defaultValue: "No, don't refresh" }), + confirmButtonText: i18next.t("productDetailEdit.refreshSitemapYes", { defaultValue: "Yes, refresh" }) + }) + .then(({ value }) => { + if (value) { + this.props.generateSitemaps(); + Alerts.toast(i18next.t("shopSettings.sitemapRefreshInitiated", { + defaultValue: "Refreshing the sitemap can take up to 5 minutes. You will be notified when it is completed." + }), "success"); + } + return false; + }) + .catch(() => false); + } + }; + handleToggleVisibility = () => { if (this.props.onProductFieldSave) { this.props.onProductFieldSave(this.product._id, "isVisible", !this.product.isVisible); @@ -313,6 +350,17 @@ class ProductAdmin extends Component { value={this.product.originCountry} options={this.props.countries} /> + {this.product && ( +
+ +
+ )}
    - {products.map((product) => ( - - ))} +
); @@ -80,7 +86,7 @@ class ProductGrid extends Component { render() { return ( -
+
{this.renderProductGrid()} {this.renderLoadingSpinner()} {this.renderNotFound()} diff --git a/imports/plugins/included/product-variant/containers/productsContainerAdmin.js b/imports/plugins/included/product-variant/containers/productsContainerAdmin.js index 6da8adec4ce..747163b007e 100644 --- a/imports/plugins/included/product-variant/containers/productsContainerAdmin.js +++ b/imports/plugins/included/product-variant/containers/productsContainerAdmin.js @@ -9,6 +9,7 @@ import { Tracker } from "meteor/tracker"; import { Reaction } from "/client/api"; import { ITEMS_INCREMENT } from "/client/config/defaults"; import { Products, Tags, Shops } from "/lib/collections"; +import { resubscribeAfterCloning } from "/lib/api/products"; import ProductsComponent from "../components/products"; const reactiveProductIds = new ReactiveVar([], (oldVal, newVal) => JSON.stringify(oldVal.sort()) === JSON.stringify(newVal.sort())); @@ -142,7 +143,16 @@ function composer(props, onData) { const editMode = !viewAsPref || viewAsPref === "administrator"; // Now that we have the necessary info, we can subscribe to Products we need - const productsSubscription = Meteor.subscribe("Products", scrollLimit, queryParams, sort, editMode); + let productsSubscription = Meteor.subscribe("Products", scrollLimit, queryParams, sort, editMode); + + // Force re-running products subscription when a product is cloned + const resubscribe = resubscribeAfterCloning.get(); + if (resubscribe) { + resubscribeAfterCloning.set(false); + productsSubscription.stop(); + productsSubscription = Meteor.subscribe("Products", scrollLimit, queryParams, sort, editMode); + } + if (productsSubscription.ready()) { window.prerenderReady = true; } diff --git a/imports/plugins/included/product-variant/containers/productsContainerCustomer.js b/imports/plugins/included/product-variant/containers/productsContainerCustomer.js index 652ae5cf14e..9e53966e7c8 100644 --- a/imports/plugins/included/product-variant/containers/productsContainerCustomer.js +++ b/imports/plugins/included/product-variant/containers/productsContainerCustomer.js @@ -4,7 +4,7 @@ import { compose } from "recompose"; import { registerComponent, composeWithTracker } from "@reactioncommerce/reaction-components"; import { Meteor } from "meteor/meteor"; import { Reaction } from "/client/api"; -import { Shops } from "/lib/collections"; +import { Accounts, Shops } from "/lib/collections"; import withCatalogItems from "imports/plugins/core/graphql/lib/hocs/withCatalogItems"; import withShopId from "/imports/plugins/core/graphql/lib/hocs/withShopId"; import withTagId from "/imports/plugins/core/graphql/lib/hocs/withTagId"; @@ -92,6 +92,9 @@ function composer(props, onData) { return; } + const userAccount = Accounts.findOne(Meteor.userId()); + const { currency: currencyCode = "" } = userAccount.profile; + // Get active shop's slug const internalShopId = Reaction.getShopId(); const pathShopSlug = Reaction.Router.getParam("shopSlug"); @@ -113,6 +116,7 @@ function composer(props, onData) { // Pass arguments to GraphQL HOCs onData(null, { + currencyCode, shopSlug, tagSlugOrId }); diff --git a/imports/plugins/included/search-mongo/server/hooks/search.js b/imports/plugins/included/search-mongo/server/hooks/search.js index d0d8722ae30..98878d7ba93 100644 --- a/imports/plugins/included/search-mongo/server/hooks/search.js +++ b/imports/plugins/included/search-mongo/server/hooks/search.js @@ -1,11 +1,9 @@ import Hooks from "@reactioncommerce/hooks"; import Logger from "@reactioncommerce/logger"; -import _ from "lodash"; import { Meteor } from "meteor/meteor"; -import { Products, ProductSearch, AccountSearch } from "/lib/collections"; +import { ProductSearch, AccountSearch } from "/lib/collections"; import rawCollections from "/imports/collections/rawCollections"; import { - getSearchParameters, buildAccountSearchRecord, buildProductSearchRecord } from "../methods/searchcollections"; @@ -64,38 +62,12 @@ Hooks.Events.add("afterRemoveProduct", (doc) => { }); /** - * after product update rebuild product search record - * @private + * @summary Rebuild search record when product is published */ -Hooks.Events.add("afterUpdateCatalogProduct", (productId, options) => { - // Find the most recent version of the product document based on - // the passed in doc._id - const productDocument = Products.findOne({ _id: productId }); - - // If this hook is ran without options, then this callback - // should no be executed. - if (!options) { - return productDocument; - } - - const { modifier: { $set: allProps } } = options; - const topLevelFieldNames = Object.getOwnPropertyNames(allProps); - - if (ProductSearch && !Meteor.isAppTest && productDocument.type === "simple") { - const { fieldSet } = getSearchParameters(); - const modifiedFields = _.intersection(fieldSet, topLevelFieldNames); - if (modifiedFields.length) { - Logger.debug(`Rewriting search record for ${productDocument.title}`); - ProductSearch.remove(productId); - if (!productDocument.isDeleted) { // do not create record if product was archived - buildProductSearchRecord(productId); - } - } else { - Logger.debug("No watched fields modified, skipping"); - } - } - - return productDocument; +Hooks.Events.add("afterPublishProductToCatalog", (product) => { + Logger.debug(`Rewriting search record for ${product.title}`); + ProductSearch.remove({ _id: product._id }); + buildProductSearchRecord(product._id); }); /** diff --git a/imports/plugins/included/sitemap-generator/client/containers/sitemap-settings-container.js b/imports/plugins/included/sitemap-generator/client/containers/sitemap-settings-container.js index 3e501442f57..3ee94dc6fbe 100644 --- a/imports/plugins/included/sitemap-generator/client/containers/sitemap-settings-container.js +++ b/imports/plugins/included/sitemap-generator/client/containers/sitemap-settings-container.js @@ -1,11 +1,12 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; -import { Meteor } from "meteor/meteor"; import { i18next } from "/client/api"; +import withGenerateSitemaps from "../hocs/withGenerateSitemaps"; import SitemapSettings from "../components/sitemap-settings"; class SitemapSettingsContainer extends Component { static propTypes = { + generateSitemaps: PropTypes.func.isRequired, packageData: PropTypes.object }; @@ -27,17 +28,10 @@ class SitemapSettingsContainer extends Component { }; handleGenerateClick = () => { - Meteor.call("sitemaps/generate", (error) => { - if (error) { - Alerts.toast(`${i18next.t("shopSettings.sitemapRefreshFailed", { - defaultValue: "Sitemap refresh failed" - })}: ${error}`, "error"); - } else { - Alerts.toast(i18next.t("shopSettings.sitemapRefreshInitiated", { - defaultValue: "Refreshing the sitemap can take up to 5 minutes. You will be notified when it is completed." - }), "success"); - } - }); + this.props.generateSitemaps(); + Alerts.toast(i18next.t("shopSettings.sitemapRefreshInitiated", { + defaultValue: "Refreshing the sitemap can take up to 5 minutes. You will be notified when it is completed." + }), "success"); }; render() { @@ -52,4 +46,4 @@ class SitemapSettingsContainer extends Component { } } -export default SitemapSettingsContainer; +export default withGenerateSitemaps(SitemapSettingsContainer); diff --git a/imports/plugins/included/sitemap-generator/client/hocs/withGenerateSitemaps.js b/imports/plugins/included/sitemap-generator/client/hocs/withGenerateSitemaps.js new file mode 100644 index 00000000000..8f86f2f7303 --- /dev/null +++ b/imports/plugins/included/sitemap-generator/client/hocs/withGenerateSitemaps.js @@ -0,0 +1,17 @@ +import React from "react"; +import { Mutation } from "react-apollo"; +import generateSitemapsMutation from "../mutations/generateSitemaps"; + +export default (Component) => ( + class GenerateSitemaps extends React.Component { + render() { + return ( + + {(generateSitemaps) => ( + + )} + + ); + } + } +); diff --git a/imports/plugins/included/sitemap-generator/client/mutations/generateSitemaps.js b/imports/plugins/included/sitemap-generator/client/mutations/generateSitemaps.js new file mode 100644 index 00000000000..30874deef0d --- /dev/null +++ b/imports/plugins/included/sitemap-generator/client/mutations/generateSitemaps.js @@ -0,0 +1,9 @@ +import gql from "graphql-tag"; + +export default gql` + mutation generateSitemaps($input: GenerateSitemapsInput) { + generateSitemaps(input: $input) { + wasJobScheduled + } + } +`; diff --git a/imports/plugins/included/sitemap-generator/register.js b/imports/plugins/included/sitemap-generator/register.js index 166f9af7f2b..edbab2df41e 100644 --- a/imports/plugins/included/sitemap-generator/register.js +++ b/imports/plugins/included/sitemap-generator/register.js @@ -1,8 +1,16 @@ import Reaction from "/imports/plugins/core/core/server/Reaction"; +import mutations from "./server/no-meteor/mutations"; +import resolvers from "./server/no-meteor/resolvers"; +import schemas from "./server/no-meteor/schemas"; Reaction.registerPackage({ label: "Sitemap Generator", name: "reaction-sitemap-generator", icon: "fa fa-vine", - autoEnable: true + autoEnable: true, + graphQL: { + resolvers, + schemas + }, + mutations }); diff --git a/imports/plugins/included/sitemap-generator/server/i18n/en.json b/imports/plugins/included/sitemap-generator/server/i18n/en.json index c41a9b18d9e..5fb80f33f34 100644 --- a/imports/plugins/included/sitemap-generator/server/i18n/en.json +++ b/imports/plugins/included/sitemap-generator/server/i18n/en.json @@ -7,6 +7,12 @@ "refreshSitemapsNow": "Refresh sitemap", "sitemapRefreshInitiated": "Refreshing the sitemap can take up to 5 minutes. You will be notified when it is completed.", "sitemapRefreshFailed": "Sitemap refresh failed" + }, + "productDetailEdit": { + "shouldAppearInSitemap": "Include in sitemap?", + "refreshSitemap": "Refresh sitemap now?", + "refreshSitemapYes": "Yes, refresh", + "refreshSitemapNo": "No, don't refresh" } } } diff --git a/imports/plugins/included/sitemap-generator/server/index.js b/imports/plugins/included/sitemap-generator/server/index.js index 41460e71793..ebcb50f2844 100644 --- a/imports/plugins/included/sitemap-generator/server/index.js +++ b/imports/plugins/included/sitemap-generator/server/index.js @@ -1,9 +1,7 @@ -import { Meteor } from "meteor/meteor"; import { WebApp } from "meteor/webapp"; import { Sitemaps } from "../lib/collections/sitemaps"; import generateSitemapsJob from "./jobs/generate-sitemaps-job"; import handleSitemapRoutes from "./middleware/handle-sitemap-routes"; -import methods from "./methods"; // Load translations import "./i18n"; @@ -16,6 +14,3 @@ generateSitemapsJob(); // Sitemap front-end routes WebApp.connectHandlers.use(handleSitemapRoutes); - -// Init methods -Meteor.methods(methods); diff --git a/imports/plugins/included/sitemap-generator/server/lib/generate-sitemaps.js b/imports/plugins/included/sitemap-generator/server/lib/generate-sitemaps.js index 8e6d011ffea..a603a55b2c9 100644 --- a/imports/plugins/included/sitemap-generator/server/lib/generate-sitemaps.js +++ b/imports/plugins/included/sitemap-generator/server/lib/generate-sitemaps.js @@ -1,8 +1,10 @@ import { Meteor } from "meteor/meteor"; import Hooks from "@reactioncommerce/hooks"; import Logger from "@reactioncommerce/logger"; -import { Notifications, Products, Shops, Tags } from "/lib/collections"; +import { Products, Shops, Tags } from "/lib/collections"; +import collections from "/imports/collections/rawCollections"; import Reaction from "/imports/plugins/core/core/server/Reaction"; +import createNotification from "/imports/plugins/included/notifications/server/no-meteor/createNotification"; import { Sitemaps } from "../../lib/collections/sitemaps"; const DEFAULT_URLS_PER_SITEMAP = 1000; @@ -32,11 +34,10 @@ export default function generateSitemaps({ shopIds = [], notifyUserId = "", urls // Notify user, if manually generated if (notifyUserId) { - Notifications.insert({ - to: notifyUserId, + createNotification(collections, { + accountId: notifyUserId, type: "sitemapGenerated", message: "Sitemap refresh is complete", - hasDetails: false, url: "/sitemap.xml" }); } @@ -195,7 +196,8 @@ function getProductSitemapItems(shopId) { shopId, type: "simple", isVisible: true, - isDeleted: false + isDeleted: false, + shouldAppearInSitemap: true }, { fields: { handle: 1, updatedAt: 1 } }).map((product) => { const { handle, updatedAt } = product; return { diff --git a/imports/plugins/included/sitemap-generator/server/methods/generate-sitemaps-method.js b/imports/plugins/included/sitemap-generator/server/methods/generate-sitemaps-method.js deleted file mode 100644 index 35dfd712273..00000000000 --- a/imports/plugins/included/sitemap-generator/server/methods/generate-sitemaps-method.js +++ /dev/null @@ -1,22 +0,0 @@ -import { Meteor } from "meteor/meteor"; -import { Jobs } from "/lib/collections"; -import Reaction from "/imports/plugins/core/core/server/Reaction"; -import { Job } from "/imports/plugins/core/job-collection/lib"; - -/** - * @name generateSitemapsMethod - * @summary Generates & stores sitemap documents for primary shop - * @memberof Methods/Sitemaps - * @returns {undefined} - */ -export default function generateSitemapsMethod() { - if (Reaction.hasAdminAccess() === false) { - throw new Meteor.Error("access-denied", "Access Denied"); - } - - this.unblock(); - - new Job(Jobs, "sitemaps/generate", { - notifyUserId: this.userId - }).save({ cancelRepeats: true }); -} diff --git a/imports/plugins/included/sitemap-generator/server/methods/index.js b/imports/plugins/included/sitemap-generator/server/methods/index.js deleted file mode 100644 index ec1570fe290..00000000000 --- a/imports/plugins/included/sitemap-generator/server/methods/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import generateSitemapsMethod from "./generate-sitemaps-method"; - -export default { - "sitemaps/generate": generateSitemapsMethod -}; diff --git a/imports/plugins/included/sitemap-generator/server/middleware/handle-sitemap-routes.js b/imports/plugins/included/sitemap-generator/server/middleware/handle-sitemap-routes.js index 4399cf7ecec..c6a746d5b24 100644 --- a/imports/plugins/included/sitemap-generator/server/middleware/handle-sitemap-routes.js +++ b/imports/plugins/included/sitemap-generator/server/middleware/handle-sitemap-routes.js @@ -24,7 +24,7 @@ export default function handleSitemapRoutes(req, res, next) { if (xml) { res.statusCode = 200; - res.send(xml); + res.end(xml); } else { res.statusCode = 404; } diff --git a/imports/plugins/included/sitemap-generator/server/no-meteor/mutations/generateSitemaps.js b/imports/plugins/included/sitemap-generator/server/no-meteor/mutations/generateSitemaps.js new file mode 100644 index 00000000000..a0a4f99d450 --- /dev/null +++ b/imports/plugins/included/sitemap-generator/server/no-meteor/mutations/generateSitemaps.js @@ -0,0 +1,21 @@ +import ReactionError from "@reactioncommerce/reaction-error"; +import { Job } from "/imports/plugins/core/job-collection/lib"; +import { Jobs } from "/lib/collections"; + +/** + * @name sitemap/generateSitemaps + * @memberof Mutations/Sitemap + * @method + * @summary Regenerates sitemap files for primary shop + * @param {Object} context - GraphQL execution context + * @return {Undefined} triggers sitemap generation job + */ +export default async function generateSitemaps(context) { + const { shopId, userHasPermission, userId } = context; + + if (userHasPermission(["admin"], shopId) === false) { + throw new ReactionError("access-denied", "User does not have permissions to generate sitemaps"); + } + + new Job(Jobs, "sitemaps/generate", { notifyUserId: userId }).save({ cancelRepeats: true }); +} diff --git a/imports/plugins/included/sitemap-generator/server/no-meteor/mutations/index.js b/imports/plugins/included/sitemap-generator/server/no-meteor/mutations/index.js new file mode 100644 index 00000000000..7359299768a --- /dev/null +++ b/imports/plugins/included/sitemap-generator/server/no-meteor/mutations/index.js @@ -0,0 +1,5 @@ +import generateSitemaps from "./generateSitemaps"; + +export default { + generateSitemaps +}; diff --git a/imports/plugins/included/sitemap-generator/server/no-meteor/resolvers/Mutation/generateSitemaps.js b/imports/plugins/included/sitemap-generator/server/no-meteor/resolvers/Mutation/generateSitemaps.js new file mode 100644 index 00000000000..37d9a7eae07 --- /dev/null +++ b/imports/plugins/included/sitemap-generator/server/no-meteor/resolvers/Mutation/generateSitemaps.js @@ -0,0 +1,20 @@ +/** + * @name Mutation.generateSitemaps + * @method + * @memberof Sitemap/GraphQL + * @summary resolver for the generateSitemaps GraphQL mutation + * @param {Object} parentResult - unused + * @param {Object} args - unused + * @param {Object} context - an object containing the per-request state + * @return {Promise} true on success + */ +export default async function generateSitemaps(parentResult, { input = {} }, context) { + const { clientMutationId = null } = input; + + await context.mutations.generateSitemaps(context); + + return { + wasJobScheduled: true, + clientMutationId + }; +} diff --git a/imports/plugins/included/sitemap-generator/server/no-meteor/resolvers/Mutation/index.js b/imports/plugins/included/sitemap-generator/server/no-meteor/resolvers/Mutation/index.js new file mode 100644 index 00000000000..7359299768a --- /dev/null +++ b/imports/plugins/included/sitemap-generator/server/no-meteor/resolvers/Mutation/index.js @@ -0,0 +1,5 @@ +import generateSitemaps from "./generateSitemaps"; + +export default { + generateSitemaps +}; diff --git a/imports/plugins/included/sitemap-generator/server/no-meteor/resolvers/index.js b/imports/plugins/included/sitemap-generator/server/no-meteor/resolvers/index.js new file mode 100644 index 00000000000..0e37c389769 --- /dev/null +++ b/imports/plugins/included/sitemap-generator/server/no-meteor/resolvers/index.js @@ -0,0 +1,5 @@ +import Mutation from "./Mutation"; + +export default { + Mutation +}; diff --git a/imports/plugins/included/sitemap-generator/server/no-meteor/schemas/index.js b/imports/plugins/included/sitemap-generator/server/no-meteor/schemas/index.js new file mode 100644 index 00000000000..cc293a21b1e --- /dev/null +++ b/imports/plugins/included/sitemap-generator/server/no-meteor/schemas/index.js @@ -0,0 +1,3 @@ +import schema from "./schema.graphql"; + +export default [schema]; diff --git a/imports/plugins/included/sitemap-generator/server/no-meteor/schemas/schema.graphql b/imports/plugins/included/sitemap-generator/server/no-meteor/schemas/schema.graphql new file mode 100644 index 00000000000..8850fa49339 --- /dev/null +++ b/imports/plugins/included/sitemap-generator/server/no-meteor/schemas/schema.graphql @@ -0,0 +1,19 @@ +extend type Mutation { + "Generate sitemap documents" + generateSitemaps(input: GenerateSitemapsInput): GenerateSitemapsPayload! +} + +"Input for the `generateSitemaps` mutation" +input GenerateSitemapsInput { + "An optional string identifying the mutation call, which will be returned in the response payload" + clientMutationId: String +} + +"Response for the `generateSitemaps` mutation" +type GenerateSitemapsPayload { + "The same string you sent with the mutation params, for matching mutation calls with their responses" + clientMutationId: String + + "Whether the sitemap generation job was successfully scheduled" + wasJobScheduled: Boolean! +} diff --git a/imports/plugins/included/ui-search/lib/components/searchModal.js b/imports/plugins/included/ui-search/lib/components/searchModal.js index 0e439dfb6d1..cb86e0322bd 100644 --- a/imports/plugins/included/ui-search/lib/components/searchModal.js +++ b/imports/plugins/included/ui-search/lib/components/searchModal.js @@ -2,8 +2,8 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import classnames from "classnames"; import { Reaction } from "/client/api"; +import CatalogGrid from "@reactioncommerce/components/CatalogGrid/v1"; import { TextField, Button, IconButton, SortableTableLegacy } from "@reactioncommerce/reaction-ui"; -import ProductGridContainer from "/imports/plugins/included/product-variant/containers/productGridContainer"; import { accountsTable } from "../helpers"; class SearchModal extends Component { @@ -22,15 +22,43 @@ class SearchModal extends Component { } state = { - activeTab: "products" + activeTab: "products", + headerSize: 0 } componentDidMount() { // Focus and select all text in the search input const { input } = this.textField.refs; input.select(); + + window.addEventListener("resize", this.updateHeaderSize); + this.updateHeaderSize(); + + // Disable scrolling for main window + document.body.style.overflow = "hidden"; + } + + componentDidUpdate(prevProps, prevState) { + if (this.getHeaderSize() !== prevState.headerSize) { + this.updateHeaderSize(); + } } + componentWillUnmount() { + window.removeEventListener("resize", this.upateHeaderSize); + + // Re-enable scrolling for main window + document.body.style.overflow = "auto"; + } + + getHeaderSize = () => { + const header = document.getElementById("search-modal-header"); + return (header && header.offsetHeight) || 0; + }; + + updateHeaderSize = () => { + this.setState({ headerSize: this.getHeaderSize() }); + }; isKeyboardAction(event) { // keyCode 32 (spacebar) @@ -159,21 +187,29 @@ class SearchModal extends Component { } render() { + const { headerSize } = this.state; + const resultsStyles = { + marginTop: headerSize, + height: window.innerHeight - headerSize + }; + return (
-
+
{this.renderSearchInput()} {this.renderSearchTypeToggle()} {this.props.tags.length > 0 && this.renderProductSearchTags()}
-
+
{this.props.products.length > 0 && - +
+ +
} {this.props.accounts.length > 0 &&
diff --git a/imports/plugins/included/ui-search/lib/containers/searchSubscription.js b/imports/plugins/included/ui-search/lib/containers/searchSubscription.js index c03b95f3595..27bb8b4cc29 100644 --- a/imports/plugins/included/ui-search/lib/containers/searchSubscription.js +++ b/imports/plugins/included/ui-search/lib/containers/searchSubscription.js @@ -2,6 +2,8 @@ import React, { Component } from "react"; import { Meteor } from "meteor/meteor"; import * as Collections from "/lib/collections"; import { Components, composeWithTracker } from "@reactioncommerce/reaction-components"; +import { Reaction, formatPriceString } from "/client/api"; +import { Media } from "/imports/plugins/core/files/client"; import SearchModal from "../components/searchModal"; class SearchSubscription extends Component { @@ -37,6 +39,11 @@ function composer(props, onData) { const searchResultsSubscription = Meteor.subscribe("SearchResults", props.searchCollection, props.value, props.facets); const shopMembersSubscription = Meteor.subscribe("ShopMembers"); + // Determine currency - user's selected or primary shop's currency + const shopCurrencyCode = Reaction.getPrimaryShopCurrency(); + const userAccount = Collections.Accounts.findOne(Meteor.userId()); + const { currency: currencyCode = "" } = userAccount.profile; + if (searchResultsSubscription.ready() && shopMembersSubscription.ready()) { const siteName = getSiteName(); let productResults = []; @@ -53,6 +60,65 @@ function composer(props, onData) { tagSearchResults = Collections.Tags.find({ _id: { $in: productHashtags } }).fetch(); + + // Subscribe to media + const productIds = productResults.map((result) => result._id); + Meteor.subscribe("ProductGridMedia", productIds); + + const productMediaById = {}; + productIds.forEach((productId) => { + const primaryMedia = Media.findOneLocal({ + "metadata.productId": productId, + "metadata.toGrid": 1, + "metadata.workflow": { $nin: ["archived", "unpublished"] } + }, { + sort: { "metadata.priority": 1, "uploadedAt": 1 } + }); + + if (primaryMedia) { + productMediaById[productId] = { + thumbnail: primaryMedia.url({ store: "thumbnail" }), + small: primaryMedia.url({ store: "small" }), + medium: primaryMedia.url({ store: "medium" }), + large: primaryMedia.url({ store: "large" }), + original: primaryMedia.url({ store: "original" }) + }; + } + }); + + // Re-format product data for CatalogGrid + productResults = productResults.map((productResult) => { + const { + _id, + description, + handle: slug, + isBackorder, + isLowQuantity, + isSoldOut, + price, + title, + vendor + } = productResult; + const primaryImage = (productMediaById[_id] && { URLs: productMediaById[_id] }) || null; + + return { + _id, + description, + isBackorder, + isLowQuantity, + isSoldOut, + pricing: [{ + currency: { + code: currencyCode || shopCurrencyCode + }, + displayPrice: formatPriceString((price && price.range) || 0) + }], + primaryImage, + slug, + title, + vendor + }; + }); } /* @@ -66,7 +132,8 @@ function composer(props, onData) { siteName, products: productResults, accounts: accountResults, - tags: tagSearchResults + tags: tagSearchResults, + currencyCode: currencyCode || shopCurrencyCode }); } } diff --git a/imports/test-utils/helpers/mockContext.js b/imports/test-utils/helpers/mockContext.js index b52d25c08ef..a29678285e6 100644 --- a/imports/test-utils/helpers/mockContext.js +++ b/imports/test-utils/helpers/mockContext.js @@ -5,6 +5,7 @@ const mockContext = { on() {} }, collections: {}, + getFunctionsOfType: jest.fn().mockName("getFunctionsOfType").mockReturnValue([]), shopId: "FAKE_SHOP_ID", userHasPermission: jest.fn().mockName("userHasPermission"), userId: "FAKE_USER_ID" diff --git a/lib/api/catalog.js b/lib/api/catalog.js index 30e2d0b126c..1548c182fe4 100644 --- a/lib/api/catalog.js +++ b/lib/api/catalog.js @@ -1,6 +1,5 @@ import _ from "lodash"; import { Products } from "/lib/collections"; -import { ReactionProduct } from "/lib/api"; /** * @file Catalog methods @@ -9,28 +8,6 @@ import { ReactionProduct } from "/lib/api"; */ export default { - /** - * @method setProduct - * @memberof Catalog - * @summary method to set default/parameterized product variant - * @param {String} currentProductId - set current productId - * @param {String} currentVariantId - set current variantId - * @return {undefined} return nothing, sets in session - */ - setProduct(currentProductId, currentVariantId) { - let productId = currentProductId; - const variantId = currentVariantId; - if (!productId.match(/^[A-Za-z0-9]{17}$/)) { - const product = Products.findOne({ - handle: productId.toLowerCase() - }); - if (product) { - productId = product._id; - } - } - ReactionProduct.setCurrentVariant(variantId); - }, - /** * @method getProductPriceRange * @memberof Catalog @@ -160,18 +137,6 @@ export default { return variant.inventoryQuantity || 0; }, - /** - * @method getProduct - * @method - * @memberof ReactionProduct - * @summary Get product object. Could be useful for products and for top level variants - * @param {String} [id] - product _id - * @return {Object} Product data - */ - getProduct(id) { - return Products.findOne(id); - }, - /** * @method getVariants * @memberof Catalog @@ -188,38 +153,6 @@ export default { }).fetch(); }, - /** - * @method getVariantParent - * @memberof Catalog - * @description Get direct parent variant - * @summary could be useful for lower level variants to get direct parents - * @param {Object} [variant] - product / variant object - * @return {Array} Parent variant or empty - */ - getVariantParent(variant) { - return Products.findOne({ - _id: { $in: variant.ancestors }, - type: "variant" - }); - }, - - /** - * @method getSiblings - * @memberof Catalog - * @description Get all sibling variants - variants with the same ancestor tree - * @summary could be useful for child variants relationships with top-level variants - * @param {Object} [variant] - product / variant object - * @param {String} [type] - type of variant - * @param {Boolean} [includeSelf] - include current variant in results - * @return {Array} Sibling variants or empty array - */ - getSiblings(variant, type) { - return Products.find({ - ancestors: variant.ancestors, - type: type || "variant" - }).fetch(); - }, - /** * @method getTopVariants * @memberof Catalog diff --git a/lib/api/products.js b/lib/api/products.js index 52909470699..e3868592528 100644 --- a/lib/api/products.js +++ b/lib/api/products.js @@ -90,27 +90,13 @@ ReactionProduct.setProduct = (currentProductId, currentVariantId) => { { handle: productId }, // Otherwise try the handle (slug) untouched { slug: productId.toLowerCase() }, // Try the slug lowercased { slug: productId }, // Otherwise try the slug untouched - { _id: productId }, // try the product id - { changedHandleWas: productId } // Last attempt: the permalink may have changed. + { _id: productId } // try the product id ] }); productId = product && product._id; if (product) { - if ( - Router.getParam("handle") !== product.handle && - product.changedHandleWas && - product.changedHandleWas !== product.handle - ) { - const newUrl = Router.pathFor("product", { - hash: { - handle: product.handle - } - }); - Router.go(newUrl); - } - // Check if selected variant id really belongs to the product. // This has been working previously rather accidentally, because variantIsSelected(variantId) below returned always false, // because the Product subscription ensured, that the correct Product is in Mini-Mongo. This is not guaranteed, though. @@ -278,7 +264,8 @@ ReactionProduct.getVariants = (id, type) => Catalog.getVariants(id || ReactionPr * @param {Boolean} [includeSelf] - include current variant in results * @return {Array} Sibling variants or empty array */ -ReactionProduct.getSiblings = (variant, type) => Catalog.getSiblings(variant, type); +ReactionProduct.getSiblings = (variant, type) => + Products.find({ ancestors: variant.ancestors, type: type || "variant" }).fetch(); /** * @method getVariantParent @@ -287,7 +274,8 @@ ReactionProduct.getSiblings = (variant, type) => Catalog.getSiblings(variant, ty * @param {Object} [variant] - product / variant object * @return {Array} Parent variant or empty */ -ReactionProduct.getVariantParent = (variant) => Catalog.getVariantParent(variant); +ReactionProduct.getVariantParent = (variant) => + Products.findOne({ _id: { $in: variant.ancestors }, type: "variant" }); /** * @method getTopVariants diff --git a/package-lock.json b/package-lock.json index 1b2c5dacd11..c4b0f82041c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "reaction", - "version": "2.0.0-rc.2", + "version": "2.0.0-rc.5", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -405,12 +405,11 @@ } }, "@babel/helper-module-imports": { - "version": "7.0.0-beta.51", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.0.0-beta.51.tgz", - "integrity": "sha1-zgBCgEX7t9XrwOp7+DV4nxU2arI=", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.0.0.tgz", + "integrity": "sha512-aP/hlLq01DWNEiDg4Jn23i+CXxW/owM4WpDLFUbpjxe4NS3BhLVZQ5i7E0ZrxuQ/vwekIeciyamgB1UIYxxM6A==", "requires": { - "@babel/types": "7.0.0-beta.51", - "lodash": "4.17.10" + "@babel/types": "7.1.3" } }, "@babel/helper-module-transforms": { @@ -1620,9 +1619,9 @@ } }, "@babel/types": { - "version": "7.0.0-beta.51", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.0.0-beta.51.tgz", - "integrity": "sha1-2AK3tUO1g2x3iqaReXq/APPZfqk=", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.1.3.tgz", + "integrity": "sha512-RpPOVfK+yatXyn8n4PB1NW6k9qjinrXrRR8ugBN8fD6hCy5RXI6PSbVqpOJBO9oSaY7Nom4ohj35feb0UR9hSA==", "requires": { "esutils": "2.0.2", "lodash": "4.17.10", @@ -1630,13 +1629,13 @@ } }, "@emotion/babel-utils": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/@emotion/babel-utils/-/babel-utils-0.6.9.tgz", - "integrity": "sha512-QN2+TP+x5QWuOGUv8TZwdMiF8PHgBQiLx646rKZBnakgc9gLYFi+gsROVxE6YTNHSaEv0fWsFjDasDyiWSJlDg==", + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/@emotion/babel-utils/-/babel-utils-0.6.10.tgz", + "integrity": "sha512-/fnkM/LTEp3jKe++T0KyTszVGWNKPNOUJfjNKLO17BzQ6QPxgbg3whayom1Qr2oLFH3V92tDymU+dT5q676uow==", "requires": { - "@emotion/hash": "0.6.5", - "@emotion/memoize": "0.6.5", - "@emotion/serialize": "0.9.0", + "@emotion/hash": "0.6.6", + "@emotion/memoize": "0.6.6", + "@emotion/serialize": "0.9.1", "convert-source-map": "1.5.1", "find-root": "1.1.0", "source-map": "0.7.3" @@ -1650,40 +1649,40 @@ } }, "@emotion/hash": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.6.5.tgz", - "integrity": "sha512-JlZbn5+adseTdDPTUkx/O1/UZbhaGR5fCLLWQDCIJ4eP9fJcVdP/qjlTveEX6mkNoJHWFbZ47wArWQQ0Qk6nMA==" + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.6.6.tgz", + "integrity": "sha512-ojhgxzUHZ7am3D2jHkMzPpsBAiB005GF5YU4ea+8DNPybMk01JJUM9V9YRlF/GE95tcOm8DxQvWA2jq19bGalQ==" }, "@emotion/memoize": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.6.5.tgz", - "integrity": "sha512-n1USr7yICA4LFIv7z6kKsXM8rZJxd1btKCBmDewlit+3OJ2j4bDfgXTAxTHYbPkHS/eztHmFWfsbxW2Pu5mDqA==" + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.6.6.tgz", + "integrity": "sha512-h4t4jFjtm1YV7UirAFuSuFGyLa+NNxjdkq6DpFLANNQY5rHueFZHVY+8Cu1HYVP6DrheB0kv4m5xPjo7eKT7yQ==" }, "@emotion/serialize": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.9.0.tgz", - "integrity": "sha512-ScuBRGxHCyAEN8YgQSsxtG5ddmP9+Of8WkxC7hidhGTxKhq3lgeCu5cFk2WdAMrpYgEd0U4g4QW/1YrCOGpAsA==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.9.1.tgz", + "integrity": "sha512-zTuAFtyPvCctHBEL8KZ5lJuwBanGSutFEncqLn/m9T1a6a93smBStK+bZzcNPgj4QS8Rkw9VTwJGhRIUVO8zsQ==", "requires": { - "@emotion/hash": "0.6.5", - "@emotion/memoize": "0.6.5", - "@emotion/unitless": "0.6.6", - "@emotion/utils": "0.8.1" + "@emotion/hash": "0.6.6", + "@emotion/memoize": "0.6.6", + "@emotion/unitless": "0.6.7", + "@emotion/utils": "0.8.2" } }, "@emotion/stylis": { - "version": "0.6.12", - "resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.6.12.tgz", - "integrity": "sha512-yS+t7l5FeYeiIyADyqjFBJvdotpphHb2S3mP4qak5BpV7ODvxuyAVF24IchEslW+A1MWHAhn5SiOW6GZIumiEQ==" + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.7.1.tgz", + "integrity": "sha512-/SLmSIkN13M//53TtNxgxo57mcJk/UJIDFRKwOiLIBEyBHEcipgR6hNMQ/59Sl4VjCJ0Z/3zeAZyvnSLPG/1HQ==" }, "@emotion/unitless": { - "version": "0.6.6", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.6.6.tgz", - "integrity": "sha512-zbd1vXRpGWCgDLsXqITReL+eqYJ95PYyWrVCCuMLBDb2LGA/HdxrZHJri6Fe+tKHihBOiCK1kbu+3Ij8aNEjzA==" + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.6.7.tgz", + "integrity": "sha512-Arj1hncvEVqQ2p7Ega08uHLr1JuRYBuO5cIvcA+WWEQ5+VmkOE3ZXzl04NbQxeQpWX78G7u6MqxKuNX3wvYZxg==" }, "@emotion/utils": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.8.1.tgz", - "integrity": "sha512-dEv1n+IFtlvLQ8/FsTOtBCC1aNT4B5abE8ODF5wk2tpWnjvgGNRMvHCeJGbVHjFfer4h8MH2w9c2/6eoJHclMg==" + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.8.2.tgz", + "integrity": "sha512-rLu3wcBWH4P5q1CGoSSH/i9hrXs7SlbRLkoq9IGuoPYNGQvDJ3pt/wmOM+XgYjIDRMVIdkUWt0RsfzF50JfnCw==" }, "@google-cloud/common": { "version": "0.17.0", @@ -1739,18 +1738,18 @@ } }, "@material-ui/core": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-1.5.0.tgz", - "integrity": "sha512-beqrCT2QTw+R9DLc+V2W2GGr/jU87L2eVTrL1kU2qfnkRz+LwJHL6YCyh0gUFgZig4ywDCIQiaMMs0/3rU3qDw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-3.2.0.tgz", + "integrity": "sha512-fThWolLWQoUMV8exLxe9NqfegFFHmV8T7kJBvk3iX47c7TrAw4Vgyvft4CApnQoYIZbrRfPMig5ovYDvmTmMCg==", "requires": { - "@babel/runtime": "7.0.0-beta.42", - "@types/jss": "9.5.4", - "@types/react-transition-group": "2.0.13", + "@babel/runtime": "7.1.2", + "@types/jss": "9.5.6", + "@types/react-transition-group": "2.0.14", "brcast": "3.0.1", "classnames": "2.2.6", "csstype": "2.5.6", "debounce": "1.2.0", - "deepmerge": "2.1.1", + "deepmerge": "2.2.1", "dom-helpers": "3.3.1", "hoist-non-react-statics": "2.5.5", "is-plain-object": "2.0.4", @@ -1765,20 +1764,18 @@ "normalize-scroll-left": "0.1.2", "popper.js": "1.14.4", "prop-types": "15.6.2", - "react-event-listener": "0.6.2", - "react-jss": "8.6.1", + "react-event-listener": "0.6.4", "react-transition-group": "2.4.0", - "recompose": "0.28.2", - "warning": "4.0.1" + "recompose": "0.30.0", + "warning": "4.0.2" }, "dependencies": { "@babel/runtime": { - "version": "7.0.0-beta.42", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.0.0-beta.42.tgz", - "integrity": "sha512-iOGRzUoONLOtmCvjUsZv3mZzgCT6ljHQY5fr1qG1QIiJQwtM7zbPWGGpa3QWETq+UqwWyJnoi5XZDZRwZDFciQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.1.2.tgz", + "integrity": "sha512-Y3SCjmhSupzFB6wcv1KmmFucH6gDVnI30WjOcicV10ju0cZjak3Jcs67YLIXBrmZYw1xCrVeJPbycFwrqNyxpg==", "requires": { - "core-js": "2.5.7", - "regenerator-runtime": "0.11.1" + "regenerator-runtime": "0.12.1" } }, "brcast": { @@ -1787,53 +1784,32 @@ "integrity": "sha512-eI3yqf9YEqyGl9PCNTR46MGvDylGtaHjalcz6Q3fAPnP/PhpKkkve52vFdfGpwp4VUvK6LUr4TQN+2stCrEwTg==" }, "deepmerge": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.1.1.tgz", - "integrity": "sha512-urQxA1smbLZ2cBbXbaYObM1dJ82aJ2H57A1C/Kklfh/ZN1bgH4G/n5KWhdNfOK11W98gqZfyYj7W4frJJRwA2w==" - }, - "react-transition-group": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.4.0.tgz", - "integrity": "sha512-Xv5d55NkJUxUzLCImGSanK8Cl/30sgpOEMGc5m86t8+kZwrPxPCPcFqyx83kkr+5Lz5gs6djuvE5By+gce+VjA==", - "requires": { - "dom-helpers": "3.3.1", - "loose-envify": "1.4.0", - "prop-types": "15.6.2", - "react-lifecycles-compat": "3.0.4" - } + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", + "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==" }, "recompose": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/recompose/-/recompose-0.28.2.tgz", - "integrity": "sha512-baVNKQBQAAAuLRnv6Cb/6/j59a1BVj6c6Pags1KXVyRB0yPfQVUZtuAUnqHDBXoR8iXPrLGWE4RNtCQ/AaRP3g==", + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/recompose/-/recompose-0.30.0.tgz", + "integrity": "sha512-ZTrzzUDa9AqUIhRk4KmVFihH0rapdCSMFXjhHbNrjAWxBuUD/guYlyysMnuHjlZC/KRiOKRtB4jf96yYSkKE8w==", "requires": { - "@babel/runtime": "7.0.0-beta.56", + "@babel/runtime": "7.1.2", "change-emitter": "0.1.6", "fbjs": "0.8.17", "hoist-non-react-statics": "2.5.5", "react-lifecycles-compat": "3.0.4", "symbol-observable": "1.2.0" - }, - "dependencies": { - "@babel/runtime": { - "version": "7.0.0-beta.56", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.0.0-beta.56.tgz", - "integrity": "sha512-vP9XV2VP013UEyZdU9eWClCsm6rQPUYHVNCfmpcv5uKviW7mKmUZq71Y5cr5dYsFKfnGDxSo8h6plUGR60lwHg==", - "requires": { - "regenerator-runtime": "0.12.1" - } - }, - "regenerator-runtime": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz", - "integrity": "sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==" - } } }, + "regenerator-runtime": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz", + "integrity": "sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==" + }, "warning": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.1.tgz", - "integrity": "sha512-rAVtTNZw+cQPjvGp1ox0XC5Q2IBFyqoqh+QII4J/oguyu83Bax1apbo2eqB8bHRS+fqYUBagys6lqUoVwKSmXQ==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.2.tgz", + "integrity": "sha512-wbTp09q/9C+jJn4KKJfJfoS6VleK/Dti0yqWSm6KMvJ4MRCXFQNapHuJXutJIrWV0Cf4AhTdeIe4qdKHR1+Hug==", "requires": { "loose-envify": "1.4.0" } @@ -1841,29 +1817,29 @@ } }, "@reactioncommerce/components": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@reactioncommerce/components/-/components-0.21.0.tgz", - "integrity": "sha512-O7x0AqK42Q4RjpgS8DCc6VyuKiLGPfuLE52sTPk1NQqOZSU0w5AU5zMnU89woem7vutUh4aUwVgs1SRpXrLJiQ==", + "version": "0.39.3", + "resolved": "https://registry.npmjs.org/@reactioncommerce/components/-/components-0.39.3.tgz", + "integrity": "sha512-dRP+0tDBHocktZbdEkB2/cbs62zqN9ntdgNkSHVyct/H/9X+Qcid4AfA9XWSzphgqCWYkZiokH5iQpEQJfOhmQ==", "requires": { - "@material-ui/core": "1.5.0", + "@material-ui/core": "3.2.0", "lodash.debounce": "4.0.8", "lodash.get": "4.4.2", "lodash.isempty": "4.4.0", "lodash.isequal": "4.5.0", "lodash.uniqueid": "4.0.1", - "mdi-material-ui": "5.2.0", + "mdi-material-ui": "5.5.0", "react-is": "16.4.2", - "react-select": "2.0.0" + "react-select": "2.1.0" }, "dependencies": { "react-select": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/react-select/-/react-select-2.0.0.tgz", - "integrity": "sha512-i2yWg8tbsY37iPimIvQ0TtIrAzxgGWQTRDZrZPQ2QVNkyHPxDartMkzf2x2Enm6wRkt9I5+pEKSIcvkwIkkiAQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-2.1.0.tgz", + "integrity": "sha512-3SdRAKX64hNzDF/DT1J1Ei3fIoQlLMkMJuB3yOY6oOYwl2A9SFJMsqXLgsveiu7UGrdo+4lyZi3mSqvw8qeGMA==", "requires": { "classnames": "2.2.6", - "emotion": "9.2.6", - "memoize-one": "4.0.0", + "emotion": "9.2.12", + "memoize-one": "4.0.2", "prop-types": "15.6.2", "raf": "3.4.0", "react-input-autosize": "2.2.1", @@ -2011,9 +1987,9 @@ "integrity": "sha512-wXAVyLfkG1UMkKOdMijVWFky39+OD/41KftzqfX1Oejd0Gm6dOIKjCihSVECg6X7PHjftxXmfOKA/d1H79ZfvQ==" }, "@types/jss": { - "version": "9.5.4", - "resolved": "https://registry.npmjs.org/@types/jss/-/jss-9.5.4.tgz", - "integrity": "sha512-FAZoCrLjEb0GUgDP1I20748eIwyshM38cXJMFJ7hW2XbtSEo8RrSebgAH3jdXF4FV4NKzyD0TvXlf2MDE8yGrA==", + "version": "9.5.6", + "resolved": "https://registry.npmjs.org/@types/jss/-/jss-9.5.6.tgz", + "integrity": "sha512-7TWmR5y1jYG4ka4wTZt65RR0kw4WgALFUWktQIWbLnDd6/z/0SQZ/4+UeH0rhdp+HEdIfmzPBH0VwE/4Z9Evzw==", "requires": { "csstype": "2.5.6", "indefinite-observable": "1.0.1" @@ -2043,9 +2019,9 @@ } }, "@types/react-transition-group": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-2.0.13.tgz", - "integrity": "sha512-A3DV2+aU1P2/wD4r2Lz3lKTV8hFjUslWmT18gGhBdMtZ6FVOAJV7HyZA7dt+Kc+pa1jjevLBhHj344Gnh8yo1g==", + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-2.0.14.tgz", + "integrity": "sha512-pa7qB0/mkhwWMBFoXhX8BcntK8G4eQl4sIfSrJCxnivTYRQWjOWf2ClR9bWdm0EUFBDHzMbKYS+QYfDtBzkY4w==", "requires": { "@types/react": "16.4.11" } @@ -2196,7 +2172,8 @@ "ansi-styles": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=" + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true }, "ansicolors": { "version": "0.2.1", @@ -2664,6 +2641,7 @@ "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=", + "dev": true, "requires": { "chalk": "1.1.3", "esutils": "2.0.2", @@ -2673,7 +2651,8 @@ "js-tokens": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", - "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=" + "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=", + "dev": true } } }, @@ -2874,6 +2853,7 @@ "version": "6.26.1", "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.26.1.tgz", "integrity": "sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA==", + "dev": true, "requires": { "babel-messages": "6.23.0", "babel-runtime": "6.26.0", @@ -2935,6 +2915,7 @@ "version": "6.24.1", "resolved": "https://registry.npmjs.org/babel-helpers/-/babel-helpers-6.24.1.tgz", "integrity": "sha1-NHHenK7DiOXIUOWX5Yom3fN2ArI=", + "dev": true, "requires": { "babel-runtime": "6.26.0", "babel-template": "6.26.0" @@ -2954,56 +2935,28 @@ "version": "6.23.0", "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.23.0.tgz", "integrity": "sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=", + "dev": true, "requires": { "babel-runtime": "6.26.0" } }, "babel-plugin-emotion": { - "version": "9.2.6", - "resolved": "https://registry.npmjs.org/babel-plugin-emotion/-/babel-plugin-emotion-9.2.6.tgz", - "integrity": "sha512-aCRXUPm2pwaUqUtpQ2Gzbn5EeOD2RyUDTQDJl5Yqwg1RLQPs3OvnB6Xt6GUrMomMISxuwFrxuWfBMajHv74UjQ==", - "requires": { - "@babel/helper-module-imports": "7.0.0-beta.51", - "@emotion/babel-utils": "0.6.9", - "@emotion/hash": "0.6.5", - "@emotion/memoize": "0.6.5", - "@emotion/stylis": "0.6.12", - "babel-core": "6.26.3", - "babel-plugin-macros": "2.4.0", + "version": "9.2.11", + "resolved": "https://registry.npmjs.org/babel-plugin-emotion/-/babel-plugin-emotion-9.2.11.tgz", + "integrity": "sha512-dgCImifnOPPSeXod2znAmgc64NhaaOjGEHROR/M+lmStb3841yK1sgaDYAYMnlvWNz8GnpwIPN0VmNpbWYZ+VQ==", + "requires": { + "@babel/helper-module-imports": "7.0.0", + "@emotion/babel-utils": "0.6.10", + "@emotion/hash": "0.6.6", + "@emotion/memoize": "0.6.6", + "@emotion/stylis": "0.7.1", + "babel-plugin-macros": "2.4.2", "babel-plugin-syntax-jsx": "6.18.0", "convert-source-map": "1.5.1", "find-root": "1.1.0", "mkdirp": "0.5.1", "source-map": "0.5.7", - "touch": "1.0.0" - }, - "dependencies": { - "babel-core": { - "version": "6.26.3", - "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.26.3.tgz", - "integrity": "sha512-6jyFLuDmeidKmUEb3NM+/yawG0M2bDZ9Z1qbZP59cyHLz8kYGKYwpJP0UwUKKUiTRNvxfLesJnTedqczP7cTDA==", - "requires": { - "babel-code-frame": "6.26.0", - "babel-generator": "6.26.1", - "babel-helpers": "6.24.1", - "babel-messages": "6.23.0", - "babel-register": "6.26.0", - "babel-runtime": "6.26.0", - "babel-template": "6.26.0", - "babel-traverse": "6.26.0", - "babel-types": "6.26.0", - "babylon": "6.18.0", - "convert-source-map": "1.5.1", - "debug": "2.6.9", - "json5": "0.5.1", - "lodash": "4.17.10", - "minimatch": "3.0.4", - "path-is-absolute": "1.0.1", - "private": "0.1.8", - "slash": "1.0.0", - "source-map": "0.5.7" - } - } + "touch": "2.0.2" } }, "babel-plugin-inline-import": { @@ -3063,11 +3016,12 @@ } }, "babel-plugin-macros": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.4.0.tgz", - "integrity": "sha512-flIBfrqAdHWn+4l2cS/4jZEyl+m5EaBHVzTb0aOF+eu/zR7E41/MoCFHPhDNL8Wzq1nyelnXeT+vcL2byFLSZw==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.4.2.tgz", + "integrity": "sha512-NBVpEWN4OQ/bHnu1fyDaAaTPAjnhXCEPqr1RwqxrU7b6tZ2hypp+zX4hlNfmVGfClD5c3Sl6Hfj5TJNF5VG5aA==", "requires": { - "cosmiconfig": "5.0.6" + "cosmiconfig": "5.0.6", + "resolve": "1.8.1" } }, "babel-plugin-module-resolver": { @@ -3185,6 +3139,7 @@ "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-register/-/babel-register-6.26.0.tgz", "integrity": "sha1-btAhFz4vy0htestFxgCahW9kcHE=", + "dev": true, "requires": { "babel-core": "6.26.3", "babel-runtime": "6.26.0", @@ -3199,6 +3154,7 @@ "version": "6.26.3", "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.26.3.tgz", "integrity": "sha512-6jyFLuDmeidKmUEb3NM+/yawG0M2bDZ9Z1qbZP59cyHLz8kYGKYwpJP0UwUKKUiTRNvxfLesJnTedqczP7cTDA==", + "dev": true, "requires": { "babel-code-frame": "6.26.0", "babel-generator": "6.26.1", @@ -3236,6 +3192,7 @@ "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-6.26.0.tgz", "integrity": "sha1-3gPi0WOWsGn0bdn/+FIfsaDjXgI=", + "dev": true, "requires": { "babel-runtime": "6.26.0", "babel-traverse": "6.26.0", @@ -3248,6 +3205,7 @@ "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "dev": true, "requires": { "babel-code-frame": "6.26.0", "babel-messages": "6.23.0", @@ -3264,6 +3222,7 @@ "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "dev": true, "requires": { "babel-runtime": "6.26.0", "esutils": "2.0.2", @@ -3274,14 +3233,16 @@ "to-fast-properties": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz", - "integrity": "sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=" + "integrity": "sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=", + "dev": true } } }, "babylon": { "version": "6.18.0", "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz", - "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==" + "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==", + "dev": true }, "balanced-match": { "version": "1.0.0", @@ -3354,6 +3315,11 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==" }, + "batch-processor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/batch-processor/-/batch-processor-1.0.0.tgz", + "integrity": "sha1-dclcMrdI4IUNEMKxaPa9vpiRrOg=" + }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -3770,6 +3736,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, "requires": { "ansi-styles": "2.2.1", "escape-string-regexp": "1.0.5", @@ -4111,6 +4078,11 @@ "resolved": "https://registry.npmjs.org/consolidated-events/-/consolidated-events-2.0.2.tgz", "integrity": "sha512-2/uRVMdRypf5z/TW/ncD/66l75P5hH2vM/GR8Jf8HLc2xnfJtmina6F6du8+v4Z2vTrMo7jC+W1tmEEuuELgkQ==" }, + "container-query-toolkit": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/container-query-toolkit/-/container-query-toolkit-0.1.3.tgz", + "integrity": "sha512-B1EvYaLzFKz81vgWDm+zL0X7fzFUjlN6lF/RivDeNT4xW9mFsTh1oiC9rtvFFiwG52e3JUmYLXwPpqNBf2AXHA==" + }, "contains-path": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz", @@ -4195,14 +4167,14 @@ } }, "create-emotion": { - "version": "9.2.6", - "resolved": "https://registry.npmjs.org/create-emotion/-/create-emotion-9.2.6.tgz", - "integrity": "sha512-4g46va26lw6DPfKF7HeWY3OI/qoaNSwpvO+li8dMydZfC6f6+ZffwlYHeIyAhGR8Z8C8c0H9J1pJbQRtb9LScw==", - "requires": { - "@emotion/hash": "0.6.5", - "@emotion/memoize": "0.6.5", - "@emotion/stylis": "0.6.12", - "@emotion/unitless": "0.6.6", + "version": "9.2.12", + "resolved": "https://registry.npmjs.org/create-emotion/-/create-emotion-9.2.12.tgz", + "integrity": "sha512-P57uOF9NL2y98Xrbl2OuiDQUZ30GVmASsv5fbsjF4Hlraip2kyAvMm+2PoYUvFFw03Fhgtxk3RqZSm2/qHL9hA==", + "requires": { + "@emotion/hash": "0.6.6", + "@emotion/memoize": "0.6.6", + "@emotion/stylis": "0.7.1", + "@emotion/unitless": "0.6.7", "csstype": "2.5.6", "stylis": "3.5.3", "stylis-rule-sheet": "0.0.10" @@ -4732,6 +4704,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-4.0.0.tgz", "integrity": "sha1-920GQ1LN9Docts5hnE7jqUdd4gg=", + "dev": true, "requires": { "repeating": "2.0.1" } @@ -4947,6 +4920,14 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.52.tgz", "integrity": "sha1-0tnxJwuko7lnuDHEDvcftNmrXOA=" }, + "element-resize-detector": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/element-resize-detector/-/element-resize-detector-1.1.13.tgz", + "integrity": "sha1-9hkH6YqRsa0hX5J5C8FRE99oRE0=", + "requires": { + "batch-processor": "1.0.0" + } + }, "email-validator": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/email-validator/-/email-validator-2.0.4.tgz", @@ -4960,12 +4941,12 @@ "dev": true }, "emotion": { - "version": "9.2.6", - "resolved": "https://registry.npmjs.org/emotion/-/emotion-9.2.6.tgz", - "integrity": "sha512-95/EiWkADklxyy1y1qlJeX5Cepa7WfpJBJSBgbLkDCBzOnP4maluvz52xcV5UaObBTfVnEBq77Go6/bgF7+xaA==", + "version": "9.2.12", + "resolved": "https://registry.npmjs.org/emotion/-/emotion-9.2.12.tgz", + "integrity": "sha512-hcx7jppaI8VoXxIWEhxpDW7I+B4kq9RNzQLmsrF6LY8BGKqe2N+gFAQr0EfuFucFlPs2A9HM4+xNj4NeqEWIOQ==", "requires": { - "babel-plugin-emotion": "9.2.6", - "create-emotion": "9.2.6" + "babel-plugin-emotion": "9.2.11", + "create-emotion": "9.2.12" } }, "encodeurl": { @@ -7091,7 +7072,8 @@ "globals": { "version": "9.18.0", "resolved": "https://registry.npmjs.org/globals/-/globals-9.18.0.tgz", - "integrity": "sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==" + "integrity": "sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==", + "dev": true }, "globby": { "version": "5.0.0", @@ -7373,6 +7355,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, "requires": { "ansi-regex": "2.1.1" } @@ -7470,6 +7453,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-2.0.0.tgz", "integrity": "sha1-42w/LSyufXRqhX440Y1fMqeILbg=", + "dev": true, "requires": { "os-homedir": "1.0.2", "os-tmpdir": "1.0.2" @@ -7932,6 +7916,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz", "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=", + "dev": true, "requires": { "number-is-nan": "1.0.1" } @@ -7944,11 +7929,6 @@ "number-is-nan": "1.0.1" } }, - "is-function": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.1.tgz", - "integrity": "sha1-Es+5i2W1fdPRk6MSH19uL0N2ArU=" - }, "is-generator-fn": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-1.0.0.tgz", @@ -9873,7 +9853,8 @@ "jsesc": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz", - "integrity": "sha1-RsP+yMGJKxKwgz25vHYiF226s0s=" + "integrity": "sha1-RsP+yMGJKxKwgz25vHYiF226s0s=", + "dev": true }, "json-parse-better-errors": { "version": "1.0.2", @@ -9913,7 +9894,8 @@ "json5": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", - "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=" + "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=", + "dev": true }, "jsonfile": { "version": "4.0.0", @@ -9959,32 +9941,11 @@ "hyphenate-style-name": "1.0.2" } }, - "jss-compose": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/jss-compose/-/jss-compose-5.0.0.tgz", - "integrity": "sha512-YofRYuiA0+VbeOw0VjgkyO380sA4+TWDrW52nSluD9n+1FWOlDzNbgpZ/Sb3Y46+DcAbOS21W5jo6SAqUEiuwA==", - "requires": { - "warning": "3.0.0" - } - }, "jss-default-unit": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/jss-default-unit/-/jss-default-unit-8.0.2.tgz", "integrity": "sha512-WxNHrF/18CdoAGw2H0FqOEvJdREXVXLazn7PQYU7V6/BWkCV0GkmWsppNiExdw8dP4TU1ma1dT9zBNJ95feLmg==" }, - "jss-expand": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/jss-expand/-/jss-expand-5.3.0.tgz", - "integrity": "sha512-NiM4TbDVE0ykXSAw6dfFmB1LIqXP/jdd0ZMnlvlGgEMkMt+weJIl8Ynq1DsuBY9WwkNyzWktdqcEW2VN0RAtQg==" - }, - "jss-extend": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jss-extend/-/jss-extend-6.2.0.tgz", - "integrity": "sha512-YszrmcB6o9HOsKPszK7NeDBNNjVyiW864jfoiHoMlgMIg2qlxKw70axZHqgczXHDcoyi/0/ikP1XaHDPRvYtEA==", - "requires": { - "warning": "3.0.0" - } - }, "jss-global": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/jss-global/-/jss-global-3.0.0.tgz", @@ -9998,36 +9959,11 @@ "warning": "3.0.0" } }, - "jss-preset-default": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/jss-preset-default/-/jss-preset-default-4.5.0.tgz", - "integrity": "sha512-qZbpRVtHT7hBPpZEBPFfafZKWmq3tA/An5RNqywDsZQGrlinIF/mGD9lmj6jGqu8GrED2SMHZ3pPKLmjCZoiaQ==", - "requires": { - "jss-camel-case": "6.1.0", - "jss-compose": "5.0.0", - "jss-default-unit": "8.0.2", - "jss-expand": "5.3.0", - "jss-extend": "6.2.0", - "jss-global": "3.0.0", - "jss-nested": "6.0.1", - "jss-props-sort": "6.0.0", - "jss-template": "1.0.1", - "jss-vendor-prefixer": "7.0.0" - } - }, "jss-props-sort": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/jss-props-sort/-/jss-props-sort-6.0.0.tgz", "integrity": "sha512-E89UDcrphmI0LzmvYk25Hp4aE5ZBsXqMWlkFXS0EtPkunJkRr+WXdCNYbXbksIPnKlBenGB9OxzQY+mVc70S+g==" }, - "jss-template": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/jss-template/-/jss-template-1.0.1.tgz", - "integrity": "sha512-m5BqEWha17fmIVXm1z8xbJhY6GFJxNB9H68GVnCWPyGYfxiAgY9WTQyvDAVj+pYRgrXSOfN5V1T4+SzN1sJTeg==", - "requires": { - "warning": "3.0.0" - } - }, "jss-vendor-prefixer": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/jss-vendor-prefixer/-/jss-vendor-prefixer-7.0.0.tgz", @@ -10636,9 +10572,9 @@ "dev": true }, "mdi-material-ui": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/mdi-material-ui/-/mdi-material-ui-5.2.0.tgz", - "integrity": "sha512-4aDmXRFaSVfq3qYmAWPnoAIc5iXf5O/6B588JiJeGIoa3dLktsLHFwWqkXI4oY4Z5IyeRpEXInr2egP7HtHzUg==" + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/mdi-material-ui/-/mdi-material-ui-5.5.0.tgz", + "integrity": "sha512-lHF8ZOZa/keyLIIj+BQsDAJHaTY1FfW5dNxsTi7qd5VeitvE8/EZXSBiAnuE8p1JmN1K6DwKQ9FkmM7fB4hLuA==" }, "media-typer": { "version": "0.3.0", @@ -10654,9 +10590,9 @@ } }, "memoize-one": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-4.0.0.tgz", - "integrity": "sha512-wdpOJ4XBejprGn/xhd1i2XR8Dv1A25FJeIvR7syQhQlz9eXsv+06llcvcmBxlWVGv4C73QBsWA8kxvZozzNwiQ==" + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-4.0.2.tgz", + "integrity": "sha512-ucx2DmXTeZTsS4GPPUZCbULAN7kdPT1G+H49Y34JjbQ5ESc6OGhVxKvb1iKhr9v19ZB9OtnHwNnhUnNR/7Wteg==" }, "merge": { "version": "1.2.0", @@ -12174,7 +12110,8 @@ "os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true }, "output-file-sync": { "version": "2.0.1", @@ -12375,8 +12312,7 @@ "path-parse": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.5.tgz", - "integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME=", - "dev": true + "integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME=" }, "path-parser": { "version": "4.2.0", @@ -12645,7 +12581,8 @@ "private": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz", - "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==" + "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==", + "dev": true }, "process": { "version": "0.11.10", @@ -13001,6 +12938,15 @@ "tinycolor2": "1.4.1" } }, + "react-container-query": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/react-container-query/-/react-container-query-0.11.0.tgz", + "integrity": "sha512-J/RaSUK0g1gIyxpFh2H1g9eMirpJG8PBtTJ2DPWmme4R8SHwIsYIiJgIGkYs59vDhWrA8dG9iUEfqU4DJpX44A==", + "requires": { + "container-query-toolkit": "0.1.3", + "resize-observer-lite": "0.2.3" + } + }, "react-copy-to-clipboard": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.1.tgz", @@ -13082,28 +13028,19 @@ } }, "react-event-listener": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/react-event-listener/-/react-event-listener-0.6.2.tgz", - "integrity": "sha512-/7s5VF+eR/LRDnGcYyiBJcNXjjCFoCo8UlX+Bn6417WMneV2f82zG1/HfCoEH6PBHqEPk3CPkMmuTDz80pzRXw==", + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/react-event-listener/-/react-event-listener-0.6.4.tgz", + "integrity": "sha512-t7VSjIuUFmN+GeyKb+wm025YLeojVB85kJL6sSs0wEBJddfmKBEQz+CNBZ2zBLKVWkPy/fZXM6U5yvojjYBVYQ==", "requires": { - "@babel/runtime": "7.0.0-beta.42", + "@babel/runtime": "7.0.0", "prop-types": "15.6.2", - "warning": "4.0.1" + "warning": "4.0.2" }, "dependencies": { - "@babel/runtime": { - "version": "7.0.0-beta.42", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.0.0-beta.42.tgz", - "integrity": "sha512-iOGRzUoONLOtmCvjUsZv3mZzgCT6ljHQY5fr1qG1QIiJQwtM7zbPWGGpa3QWETq+UqwWyJnoi5XZDZRwZDFciQ==", - "requires": { - "core-js": "2.5.7", - "regenerator-runtime": "0.11.1" - } - }, "warning": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.1.tgz", - "integrity": "sha512-rAVtTNZw+cQPjvGp1ox0XC5Q2IBFyqoqh+QII4J/oguyu83Bax1apbo2eqB8bHRS+fqYUBagys6lqUoVwKSmXQ==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.2.tgz", + "integrity": "sha512-wbTp09q/9C+jJn4KKJfJfoS6VleK/Dti0yqWSm6KMvJ4MRCXFQNapHuJXutJIrWV0Cf4AhTdeIe4qdKHR1+Hug==", "requires": { "loose-envify": "1.4.0" } @@ -13165,18 +13102,6 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.4.2.tgz", "integrity": "sha512-rI3cGFj/obHbBz156PvErrS5xc6f1eWyTwyV4mo0vF2lGgXgS+mm7EKD5buLJq6jNgIagQescGSVG2YzgXt8Yg==" }, - "react-jss": { - "version": "8.6.1", - "resolved": "https://registry.npmjs.org/react-jss/-/react-jss-8.6.1.tgz", - "integrity": "sha512-SH6XrJDJkAphp602J14JTy3puB2Zxz1FkM3bKVE8wON+va99jnUTKWnzGECb3NfIn9JPR5vHykge7K3/A747xQ==", - "requires": { - "hoist-non-react-statics": "2.5.5", - "jss": "9.8.7", - "jss-preset-default": "4.5.0", - "prop-types": "15.6.2", - "theming": "1.3.0" - } - }, "react-lifecycles-compat": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", @@ -13719,6 +13644,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", + "dev": true, "requires": { "is-finite": "1.0.2" } @@ -13855,6 +13781,14 @@ "integrity": "sha1-79qpjqdFEyTQkrKyFjpqHXqaIUc=", "dev": true }, + "resize-observer-lite": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/resize-observer-lite/-/resize-observer-lite-0.2.3.tgz", + "integrity": "sha512-k/p+pjCTQkQ7x94bWsxcVwEJI5SrcO95j7czrCKMpHjXFQ+HmKRGLTdAkZoL3+wG1Pe/4L9Sl652zy9lU54dFg==", + "requires": { + "element-resize-detector": "1.1.13" + } + }, "resize-observer-polyfill": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.0.tgz", @@ -13864,7 +13798,6 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.8.1.tgz", "integrity": "sha512-AicPrAC7Qu1JxPCZ9ZgCZlY35QgFnNqc+0LtbRNxnVw4TXvjQ72wnuL9JQcEBgXkI9JM8MsT9kaQoHcpCRJOYA==", - "dev": true, "requires": { "path-parse": "1.0.5" } @@ -14337,7 +14270,8 @@ "slash": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", - "integrity": "sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=" + "integrity": "sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=", + "dev": true }, "slice-ansi": { "version": "1.0.0", @@ -14902,6 +14836,7 @@ "version": "0.4.18", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz", "integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==", + "dev": true, "requires": { "source-map": "0.5.7" } @@ -15222,7 +15157,8 @@ "supports-color": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true }, "sweetalert2": { "version": "7.25.6", @@ -15512,24 +15448,6 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, - "theming": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/theming/-/theming-1.3.0.tgz", - "integrity": "sha512-ya5Ef7XDGbTPBv5ENTwrwkPUexrlPeiAg/EI9kdlUAZhNlRbCdhMKRgjNX1IcmsmiPcqDQZE6BpSaH+cr31FKw==", - "requires": { - "brcast": "3.0.1", - "is-function": "1.0.1", - "is-plain-object": "2.0.4", - "prop-types": "15.6.2" - }, - "dependencies": { - "brcast": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/brcast/-/brcast-3.0.1.tgz", - "integrity": "sha512-eI3yqf9YEqyGl9PCNTR46MGvDylGtaHjalcz6Q3fAPnP/PhpKkkve52vFdfGpwp4VUvK6LUr4TQN+2stCrEwTg==" - } - } - }, "then-fs": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/then-fs/-/then-fs-2.0.0.tgz", @@ -15651,9 +15569,9 @@ "dev": true }, "touch": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/touch/-/touch-1.0.0.tgz", - "integrity": "sha1-RJy+LbrlqMgDjjDXH6D/RklHxN4=", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/touch/-/touch-2.0.2.tgz", + "integrity": "sha512-qjNtvsFXTRq7IuMLweVgFxmEuQ6gLbRs2jQxL80TtZ31dEKWYIxRXquij6w6VimyDek5hD3PytljHmEtAs2u0A==", "requires": { "nopt": "1.0.10" } @@ -15699,9 +15617,9 @@ "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", "requires": { - "string-width": "^1.0.1", - "strip-ansi": "^3.0.1", - "wrap-ansi": "^2.0.0" + "string-width": "1.0.2", + "strip-ansi": "3.0.1", + "wrap-ansi": "2.1.0" }, "dependencies": { "string-width": { @@ -15709,9 +15627,9 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "requires": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" + "code-point-at": "1.1.0", + "is-fullwidth-code-point": "1.0.0", + "strip-ansi": "3.0.1" } } } @@ -15721,19 +15639,19 @@ "resolved": "https://registry.npmjs.org/yargs/-/yargs-8.0.2.tgz", "integrity": "sha1-YpmpBVsc78lp/355wdkY3Osiw2A=", "requires": { - "camelcase": "^4.1.0", - "cliui": "^3.2.0", - "decamelize": "^1.1.1", - "get-caller-file": "^1.0.1", - "os-locale": "^2.0.0", - "read-pkg-up": "^2.0.0", - "require-directory": "^2.1.1", - "require-main-filename": "^1.0.1", - "set-blocking": "^2.0.0", - "string-width": "^2.0.0", - "which-module": "^2.0.0", - "y18n": "^3.2.1", - "yargs-parser": "^7.0.0" + "camelcase": "4.1.0", + "cliui": "3.2.0", + "decamelize": "1.2.0", + "get-caller-file": "1.0.3", + "os-locale": "2.1.0", + "read-pkg-up": "2.0.0", + "require-directory": "2.1.1", + "require-main-filename": "1.0.1", + "set-blocking": "2.0.0", + "string-width": "2.1.1", + "which-module": "2.0.0", + "y18n": "3.2.1", + "yargs-parser": "7.0.0" } } } diff --git a/package.json b/package.json index 3886e387e85..ad69ea18973 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "reaction", "description": "Reaction is a modern reactive, real-time event driven ecommerce platform.", - "version": "2.0.0-rc.2", + "version": "2.0.0-rc.5", "main": "main.js", "directories": { "test": "tests" @@ -29,7 +29,7 @@ "@babel/plugin-syntax-dynamic-import": "7.0.0", "@babel/plugin-syntax-import-meta": "7.0.0", "@babel/runtime": "7.0.0", - "@reactioncommerce/components": "0.21.0", + "@reactioncommerce/components": "0.39.3", "@reactioncommerce/components-context": "1.0.0", "@reactioncommerce/data-factory": "^1.0.0", "@reactioncommerce/file-collections": "0.6.0", @@ -112,6 +112,7 @@ "react-autosuggest": "^9.3.3", "react-avatar": "^2.5.1", "react-color": "^2.13.8", + "react-container-query": "^0.11.0", "react-copy-to-clipboard": "^5.0.1", "react-dates": "17.1.0", "react-dnd": "^2.5.4", @@ -201,7 +202,7 @@ "scripts": { "devserver": "ROOT_URL=http://localhost:3030 MONGO_URL=mongodb://localhost:27017/reaction NODE_ENV=reaction-node BABEL_DISABLE_CACHE=1 nodemon ./imports/node-app/devserver/index.js", "lint": "eslint .", - "test": "npm run test:unit && npm run test:integration && npm run test:app", + "test": "npm run test:unit && npm run test:app", "test:app": "MONGO_URL='' TEST_CLIENT=0 meteor test --once --full-app --driver-package meteortesting:mocha", "test:app:watch": "MONGO_URL='' TEST_CLIENT=0 TEST_WATCH=1 meteor test --full-app --driver-package meteortesting:mocha", "test:unit": "NODE_ENV=jesttest BABEL_DISABLE_CACHE=1 jest --no-cache --maxWorkers=4 --testPathIgnorePatterns /tests/", diff --git a/tests/TestApp.js b/tests/TestApp.js index 2344a6eccb3..5a4ecc17bd3 100644 --- a/tests/TestApp.js +++ b/tests/TestApp.js @@ -11,6 +11,8 @@ import hashLoginToken from "../imports/node-app/core/util/hashLoginToken"; import setUpFileCollections from "../imports/plugins/core/files/server/no-meteor/setUpFileCollections"; 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"; class TestApp { constructor() { @@ -30,6 +32,8 @@ class TestApp { mutations, queries }, + typeDefs: schemas, + resolvers, debug: true }); } diff --git a/tests/catalog/PublishProductsToCatalogMutation.graphql b/tests/catalog/PublishProductsToCatalogMutation.graphql index c2a09090a4e..3df8e10e566 100644 --- a/tests/catalog/PublishProductsToCatalogMutation.graphql +++ b/tests/catalog/PublishProductsToCatalogMutation.graphql @@ -4,11 +4,14 @@ productId title isDeleted + supportedFulfillmentTypes variants { _id + price title options { _id + price title } } diff --git a/tests/catalog/publishProductsToCatalog.test.js b/tests/catalog/publishProductsToCatalog.test.js index 58f302dc052..98b490769f1 100644 --- a/tests/catalog/publishProductsToCatalog.test.js +++ b/tests/catalog/publishProductsToCatalog.test.js @@ -22,9 +22,9 @@ const mockProduct = { ancestors: [], title: "Fake Product", shopId: internalShopId, - supportedFulfillmentTypes: ["shipping"], isDeleted: false, - isVisible: true + isVisible: true, + supportedFulfillmentTypes: ["shipping"] }; const mockVariant = { diff --git a/tests/meteor/product-publications.app-test.js b/tests/meteor/product-publications.app-test.js index a5be54f1325..89d5d040ffd 100644 --- a/tests/meteor/product-publications.app-test.js +++ b/tests/meteor/product-publications.app-test.js @@ -378,7 +378,7 @@ describe("Publication", function () { function publishProducts() { const productIds = Collections.Products.find({}).fetch().map((product) => product._id); - return Promise.await(publishProductsToCatalog(productIds, collections)); + return Promise.await(publishProductsToCatalog(productIds, { collections, getFunctionsOfType: () => [] })); } }); @@ -391,7 +391,7 @@ describe("Publication", function () { const product = Collections.Products.findOne({ isVisible: true }); - Promise.await(publishProductToCatalog(product, collections)); + Promise.await(publishProductToCatalog(product, { collections, getFunctionsOfType: () => [] })); sandbox.stub(Reaction, "getShopId", () => shopId); diff --git a/tests/mocks/mockCatalogItems.js b/tests/mocks/mockCatalogItems.js index d71b438f3a3..94dc194afc1 100644 --- a/tests/mocks/mockCatalogItems.js +++ b/tests/mocks/mockCatalogItems.js @@ -1,3 +1,4 @@ +import { cloneDeep } from "lodash"; import { internalShopId } from "./mockShop"; import { internalCatalogItemIds, @@ -33,21 +34,49 @@ export const mockCatalogItems = [ } ]; +/** + * Mock absolute URLs in catalog products when returned from GraphQL + */ +const mockExternalCatalogProductNodes = []; +const siteURL = "https://shop.fake.site"; + +function mockMediaURLsResponse(URLs) { + const { large, medium, original, small, thumbnail } = URLs; + return { + thumbnail: `${siteURL}${thumbnail}`, + small: `${siteURL}${small}`, + medium: `${siteURL}${medium}`, + large: `${siteURL}${large}`, + original: `${siteURL}${original}` + }; +} + +mockExternalCatalogProducts.forEach((mockExternalCatalogProduct) => { + const cloned = cloneDeep(mockExternalCatalogProduct); + cloned.product.primaryImage.URLs = mockMediaURLsResponse(cloned.product.primaryImage.URLs); + cloned.product.media.forEach((media) => { + media.URLs = mockMediaURLsResponse(media.URLs); + }); + + mockExternalCatalogProductNodes.push(cloned); +}); + /** * mock unsorted catalogItems query response */ export const mockUnsortedCatalogItemsResponse = { catalogItems: { - nodes: mockExternalCatalogProducts + nodes: mockExternalCatalogProductNodes } }; + /** * mock sorted by minPrice high to low catalogItems query response */ export const mockSortedByPriceHigh2LowCatalogItemsResponse = { catalogItems: { - nodes: [mockExternalCatalogProducts[1], mockExternalCatalogProducts[0]] + nodes: [mockExternalCatalogProductNodes[1], mockExternalCatalogProductNodes[0]] } }; @@ -56,6 +85,6 @@ export const mockSortedByPriceHigh2LowCatalogItemsResponse = { */ export const mockSortedByPriceLow2HighCatalogItemsResponse = { catalogItems: { - nodes: [mockExternalCatalogProducts[0], mockExternalCatalogProducts[1]] + nodes: [mockExternalCatalogProductNodes[0], mockExternalCatalogProductNodes[1]] } }; diff --git a/tests/mocks/mockCatalogProducts.js b/tests/mocks/mockCatalogProducts.js index c78b387a80f..e90b590c4d5 100644 --- a/tests/mocks/mockCatalogProducts.js +++ b/tests/mocks/mockCatalogProducts.js @@ -407,11 +407,11 @@ export const mockInternalCatalogProducts = [ productId: internalProductIds[0], variantId: null, URLs: { - thumbnail: "thumbnail", - small: "small", - medium: "medium", - large: "large", - original: "original" + thumbnail: "/thumbnail", + small: "/small", + medium: "/medium", + large: "/large", + original: "/original" } } ], @@ -421,11 +421,11 @@ export const mockInternalCatalogProducts = [ productId: internalProductIds[0], variantId: null, URLs: { - thumbnail: "thumbnail", - small: "small", - medium: "medium", - large: "large", - original: "original" + thumbnail: "/thumbnail", + small: "/small", + medium: "/medium", + large: "/large", + original: "/original" } }, productType: "productType", @@ -506,11 +506,11 @@ export const mockInternalCatalogProducts = [ productId: internalProductIds[1], variantId: null, URLs: { - thumbnail: "thumbnail", - small: "small", - medium: "medium", - large: "large", - original: "original" + thumbnail: "/thumbnail", + small: "/small", + medium: "/medium", + large: "/large", + original: "/original" } } ], @@ -520,11 +520,11 @@ export const mockInternalCatalogProducts = [ productId: internalProductIds[1], variantId: null, URLs: { - thumbnail: "thumbnail", - small: "small", - medium: "medium", - large: "large", - original: "original" + thumbnail: "/thumbnail", + small: "/small", + medium: "/medium", + large: "/large", + original: "/original" } }, productType: "productType", @@ -616,11 +616,11 @@ export const mockExternalCatalogProducts = [ productId: opaqueProductIds[0], variantId: null, URLs: { - thumbnail: "https://shop.fake.site/thumbnail", - small: "https://shop.fake.site/small", - medium: "https://shop.fake.site/medium", - large: "https://shop.fake.site/large", - original: "https://shop.fake.site/original" + thumbnail: "/thumbnail", + small: "/small", + medium: "/medium", + large: "/large", + original: "/original" } } ], @@ -630,11 +630,11 @@ export const mockExternalCatalogProducts = [ productId: opaqueProductIds[0], variantId: null, URLs: { - thumbnail: "https://shop.fake.site/thumbnail", - small: "https://shop.fake.site/small", - medium: "https://shop.fake.site/medium", - large: "https://shop.fake.site/large", - original: "https://shop.fake.site/original" + thumbnail: "/thumbnail", + small: "/small", + medium: "/medium", + large: "/large", + original: "/original" } }, productType: "productType", @@ -725,11 +725,11 @@ export const mockExternalCatalogProducts = [ productId: opaqueProductIds[1], variantId: null, URLs: { - thumbnail: "https://shop.fake.site/thumbnail", - small: "https://shop.fake.site/small", - medium: "https://shop.fake.site/medium", - large: "https://shop.fake.site/large", - original: "https://shop.fake.site/original" + thumbnail: "/thumbnail", + small: "/small", + medium: "/medium", + large: "/large", + original: "/original" } } ], @@ -739,11 +739,11 @@ export const mockExternalCatalogProducts = [ productId: opaqueProductIds[1], variantId: null, URLs: { - thumbnail: "https://shop.fake.site/thumbnail", - small: "https://shop.fake.site/small", - medium: "https://shop.fake.site/medium", - large: "https://shop.fake.site/large", - original: "https://shop.fake.site/original" + thumbnail: "/thumbnail", + small: "/small", + medium: "/medium", + large: "/large", + original: "/original" } }, productType: "productType", diff --git a/tests/waitForReplica.test.js b/tests/waitForMongo.test.js similarity index 96% rename from tests/waitForReplica.test.js rename to tests/waitForMongo.test.js index 99cdf75ac93..122bca0472f 100644 --- a/tests/waitForReplica.test.js +++ b/tests/waitForMongo.test.js @@ -1,4 +1,4 @@ -import { checkWaitRetry } from "../.reaction/waitForReplica"; +import { checkWaitRetry } from "../.reaction/waitForMongo"; test("should work in immediate success case", async () => { const outLog = [];